diff --git a/doc/sphinx-guides/source/_static/installation/files/etc/shibboleth/shibboleth2.xml b/doc/sphinx-guides/source/_static/installation/files/etc/shibboleth/shibboleth2.xml index dc79aebde38..8aa95b1c10c 100644 --- a/doc/sphinx-guides/source/_static/installation/files/etc/shibboleth/shibboleth2.xml +++ b/doc/sphinx-guides/source/_static/installation/files/etc/shibboleth/shibboleth2.xml @@ -41,6 +41,7 @@ https://wiki.shibboleth.net/confluence/display/SHIB2/NativeSPConfiguration + diff --git a/doc/sphinx-guides/source/admin/index.rst b/doc/sphinx-guides/source/admin/index.rst index e632192aed8..35296d86a62 100755 --- a/doc/sphinx-guides/source/admin/index.rst +++ b/doc/sphinx-guides/source/admin/index.rst @@ -19,4 +19,8 @@ These "superuser" tasks are managed via the new page called the Dashboard. A use metadataexport timers geoconnect-worldmap + user-administration + solr-search-index + monitoring + maintenance troubleshooting diff --git a/doc/sphinx-guides/source/admin/maintenance.rst b/doc/sphinx-guides/source/admin/maintenance.rst new file mode 100644 index 00000000000..09eadb097c4 --- /dev/null +++ b/doc/sphinx-guides/source/admin/maintenance.rst @@ -0,0 +1,9 @@ +Maintenance +=========== + +.. contents:: Contents: + :local: + +When you have scheduled down time for your production servers, we provide a :download:`sample maintenance page <../_static/installation/files/etc/maintenance/maintenance.xhtml>` for you to use. To download, right-click and select "Save Link As". + +The maintenance page is intended to be a static page served by Apache to provide users with a nicer, more informative experience when the site is unavailable. \ No newline at end of file diff --git a/doc/sphinx-guides/source/admin/monitoring.rst b/doc/sphinx-guides/source/admin/monitoring.rst new file mode 100644 index 00000000000..5e2eb95abca --- /dev/null +++ b/doc/sphinx-guides/source/admin/monitoring.rst @@ -0,0 +1,11 @@ +Monitoring +=========== + +.. contents:: Contents: + :local: + +In production you'll want to monitor the usual suspects such as CPU, memory, free disk space, etc. + +https://github.com/IQSS/dataverse/issues/2595 contains some information on enabling monitoring of Glassfish, which is disabled by default. + +There is a database table called ``actionlogrecord`` that captures events that may be of interest. See https://github.com/IQSS/dataverse/issues/2729 for more discussion around this table. diff --git a/doc/sphinx-guides/source/admin/solr-search-index.rst b/doc/sphinx-guides/source/admin/solr-search-index.rst new file mode 100644 index 00000000000..a599d9c4daa --- /dev/null +++ b/doc/sphinx-guides/source/admin/solr-search-index.rst @@ -0,0 +1,47 @@ +Solr Search Index +================= + +Dataverse requires Solr to be operational at all times. If you stop Solr, you should see a error about this on the home page, which is powered by the search index Solr provides. You can set up Solr by following the steps in our Installation Guide's :doc:`/installation/prerequisites` and :doc:`/installation/config` sections explaining how to configure it. This section you're reading now is about the care and feeding of the search index. PostgreSQL is the "source of truth" and the Dataverse application will copy data from PostgreSQL into Solr. For this reason, the search index can be rebuilt at any time. Depending on the amount of data you have, this can be a slow process. You are encouraged to experiment with production data to get a sense of how long a full reindexing will take. + +.. contents:: Contents: + :local: + +Full Reindex +------------- + +There are two ways to perform a full reindex of the Dataverse search index. Starting with a "clear" ensures a completely clean index but involves downtime. Reindexing in place doesn't involve downtime but does not ensure a completely clean index. + +Clear and Reindex ++++++++++++++++++ + +Clearing Data from Solr +~~~~~~~~~~~~~~~~~~~~~~~ + +Please note that the moment you issue this command, it will appear to end users looking at the home page that all data is gone! This is because the home page is powered by the search index. + +``curl http://localhost:8080/api/admin/index/clear`` + +Start Async Reindex +~~~~~~~~~~~~~~~~~~~ + +Please note that this operation may take hours depending on the amount of data in your system. This known issue is being tracked at https://github.com/IQSS/dataverse/issues/50 + +``curl http://localhost:8080/api/admin/index`` + +Reindex in Place ++++++++++++++++++ + +An alternative to completely clearing the search index is to reindex in place. + +Clear Index Timestamps +~~~~~~~~~~~~~~~~~~~~~~ + +``curl -X DELETE http://localhost:8080/api/admin/index/timestamps`` + +Start or Continue Async Reindex +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If indexing stops, this command should pick up where it left off based on which index timestamps have been set, which is why we start by clearing these timestamps above. These timestamps are stored in the ``dvobject`` database table. + +``curl http://localhost:8080/api/admin/index/continue`` + diff --git a/doc/sphinx-guides/source/admin/troubleshooting.rst b/doc/sphinx-guides/source/admin/troubleshooting.rst index fe9e8b7c659..fb7ed8a8326 100644 --- a/doc/sphinx-guides/source/admin/troubleshooting.rst +++ b/doc/sphinx-guides/source/admin/troubleshooting.rst @@ -5,9 +5,19 @@ Troubleshooting This new (as of v.4.6) section of the Admin guide is for tips on how to diagnose and fix system problems. -.. contents:: |toctitle| +.. contents:: Contents: :local: +Glassfish +--------- + +``server.log`` is the main place to look when you encounter problems. Hopefully an error message has been logged. If there's a stack trace, it may be of interest to developers, especially they can trace line numbers back to a tagged version. + +For debugging purposes, you may find it helpful to increase logging levels as mentioned in the :doc:`/developers/debugging` section of the Developer Guide. + +Our guides focus on using the command line to manage Glassfish but you might be interested in an admin GUI at http://localhost:4848 + + Deployment fails, "EJB Timer Service not available" --------------------------------------------------- diff --git a/doc/sphinx-guides/source/admin/user-administration.rst b/doc/sphinx-guides/source/admin/user-administration.rst new file mode 100644 index 00000000000..370947547d3 --- /dev/null +++ b/doc/sphinx-guides/source/admin/user-administration.rst @@ -0,0 +1,39 @@ +User Administration +=================== + +This section focuses on user administration tools and tasks. + +.. contents:: Contents: + :local: + +Manage Users Table +------------------ + +The Manage Users table gives the network administrator a list of all user accounts in table form. It lists username, full name, email address, and whether or not the user has Superuser status. + +Usernames are listed alphabetically and clicking on a username takes you to the account page that contains detailed information on that account. + +You can access the Manage Users table by clicking the "Manage Users" button on the Dashboard, which is linked from the header of all Dataverse pages (if you're loggied in as an administrator). + +Confirm Email +------------- + +Dataverse encourages builtin/local users to verify their email address upon signup or email change so that sysadmins can be assured that users can be contacted. + +The app will send a standard welcome email with a URL the user can click, which, when activated, will store a ``lastconfirmed`` timestamp in the ``authenticateduser`` table of the database. Any time this is "null" for a user (immediately after signup and/or changing of their Dataverse email address), their current email on file is considered to not be verified. The link that is sent expires after a time (the default is 24 hours), but this is configurable by a superuser via the ``:MinutesUntilConfirmEmailTokenExpires`` config option. + +Should users' URL token expire, they will see a "Verify Email" button on the account information page to send another URL. + +Sysadmins can determine which users have verified their email addresses by looking for the presence of the value ``emailLastConfirmed`` in the JSON output from listing users (see the "Admin" section of the :doc:`/api/native-api`). As mentioned in the :doc:`/user/account` section of the User Guide, the email addresses for Shibboleth users are re-confirmed on every login. + +Deleting an API Token +--------------------- + +If an API token is compromised it should be deleted. Users can generate a new one for themselves as explained in the :doc:`/user/account` section of the User Guide, but you may want to preemptively delete tokens from the database. + +Using the API token 7ae33670-be21-491d-a244-008149856437 as an example: + +``delete from apitoken where tokenstring = '7ae33670-be21-491d-a244-008149856437';`` + +You should expect the output ``DELETE 1`` after issuing the command above. + diff --git a/doc/sphinx-guides/source/api/intro.rst b/doc/sphinx-guides/source/api/intro.rst index d2f6c4e2487..a9543dff0a5 100755 --- a/doc/sphinx-guides/source/api/intro.rst +++ b/doc/sphinx-guides/source/api/intro.rst @@ -21,7 +21,7 @@ We use the term "native" to mean that the API is not based on any standard. For Authentication -------------- -Most Dataverse APIs require the use of an API token. Instructions for getting a token are described in the :doc:`/user/account` section of the User Guide. +Most Dataverse APIs require the use of an API token. (In code we sometimes call it a "key" because it's shorter.) Instructions for getting a token are described in the :doc:`/user/account` section of the User Guide. There are two ways to pass your API token to Dataverse APIs. The preferred method is to send the token in the ``X-Dataverse-key`` HTTP header, as in the following curl example:: diff --git a/doc/sphinx-guides/source/api/native-api.rst b/doc/sphinx-guides/source/api/native-api.rst index adaff1ff237..846eb01683c 100644 --- a/doc/sphinx-guides/source/api/native-api.rst +++ b/doc/sphinx-guides/source/api/native-api.rst @@ -559,9 +559,110 @@ Creates a global role in the Dataverse installation. The data POSTed are assumed POST http://$SERVER/api/admin/roles -List all users:: +List users with the options to search and "page" through results. Only accessible to superusers. Optional parameters: + +* ``searchTerm`` A string that matches the beginning of a user identifier, first name, last name or email address. +* ``itemsPerPage`` The number of detailed results to return. The default is 25. This number has no limit. e.g. You could set it to 1000 to return 1,000 results +* ``selectedPage`` The page of results to return. The default is 1. + + GET http://$SERVER/api/admin/list-users + + +Sample output appears below. + +* When multiple pages of results exist, the ``selectedPage`` parameters may be specified. +* Note, the resulting ``pagination`` section includes ``pageCount``, ``previousPageNumber``, ``nextPageNumber``, and other variables that may be used to re-create the UI. + +.. code-block:: json + + { + "status":"OK", + "data":{ + "userCount":27, + "selectedPage":1, + "pagination":{ + "isNecessary":true, + "numResults":27, + "numResultsString":"27", + "docsPerPage":25, + "selectedPageNumber":1, + "pageCount":2, + "hasPreviousPageNumber":false, + "previousPageNumber":1, + "hasNextPageNumber":true, + "nextPageNumber":2, + "startResultNumber":1, + "endResultNumber":25, + "startResultNumberString":"1", + "endResultNumberString":"25", + "remainingResults":2, + "numberNextResults":2, + "pageNumberList":[ + 1, + 2 + ] + }, + "bundleStrings":{ + "userId":"ID", + "userIdentifier":"Username", + "lastName":"Last Name ", + "firstName":"First Name ", + "email":"Email", + "affiliation":"Affiliation", + "position":"Position", + "isSuperuser":"Superuser", + "authenticationProvider":"Authentication", + "roles":"Roles", + "createdTime":"Created Time", + "lastLoginTime":"Last Login Time", + "lastApiUseTime":"Last API Use Time" + }, + "users":[ + { + "id":8, + "userIdentifier":"created1", + "lastName":"created1", + "firstName":"created1", + "email":"created1@g.com", + "affiliation":"hello", + "isSuperuser":false, + "authenticationProvider":"BuiltinAuthenticationProvider", + "roles":"Curator", + "createdTime":"2017-06-28 10:36:29.444" + }, + { + "id":9, + "userIdentifier":"created8", + "lastName":"created8", + "firstName":"created8", + "email":"created8@g.com", + "isSuperuser":false, + "authenticationProvider":"BuiltinAuthenticationProvider", + "roles":"Curator", + "createdTime":"2000-01-01 00:00:00.0" + }, + { + "id":1, + "userIdentifier":"dataverseAdmin", + "lastName":"Admin", + "firstName":"Dataverse", + "email":"dataverse@mailinator2.com", + "affiliation":"Dataverse.org", + "position":"Admin", + "isSuperuser":true, + "authenticationProvider":"BuiltinAuthenticationProvider", + "roles":"Admin, Contributor", + "createdTime":"2000-01-01 00:00:00.0", + "lastLoginTime":"2017-07-03 12:22:35.926", + "lastApiUseTime":"2017-07-03 12:55:57.186" + }, + **... 22 more user documents ...** + ] + } + } + +.. note:: "List all users" ``GET http://$SERVER/api/admin/authenticatedUsers`` is deprecated, but supported. - GET http://$SERVER/api/admin/authenticatedUsers List user whose ``identifier`` (without the ``@`` sign) is passed:: diff --git a/doc/sphinx-guides/source/installation/administration.rst b/doc/sphinx-guides/source/installation/administration.rst deleted file mode 100644 index ed1bb0fd775..00000000000 --- a/doc/sphinx-guides/source/installation/administration.rst +++ /dev/null @@ -1,104 +0,0 @@ -Administration -============== - -This section focuses on system and database administration tasks. Please see the :doc:`/user/index` for tasks having to do with having the "Admin" role on a dataverse or dataset. - -.. contents:: |toctitle| - :local: - -Solr Search Index ------------------ - -Dataverse requires Solr to be operational at all times. If you stop Solr, you should see a error about this on the home page, which is powered by the search index Solr provides. You set up Solr by following the steps in the :doc:`prerequisites` section and the :doc:`config` section explained how to configure it. This section is about the care and feeding of the search index. PostgreSQL is the "source of truth" and the Dataverse application will copy data from PostgreSQL into Solr. For this reason, the search index can be rebuilt at any time but depending on the amount of data you have, this can be a slow process. You are encouraged to experiment with production data to get a sense of how long a full reindexing will take. - -Full Reindex -++++++++++++ - -There are two ways to perform a full reindex of the Dataverse search index. Starting with a "clear" ensures a completely clean index but involves downtime. Reindexing in place doesn't involve downtime but does not ensure a completely clean index. - -Clear and Reindex -~~~~~~~~~~~~~~~~~ - -**Clearing Data from Solr** -........................... - -Please note that the moment you issue this command, it will appear to end users looking at the home page that all data is gone! This is because the home page is powered by the search index. - -``curl http://localhost:8080/api/admin/index/clear`` - -**Start Async Reindex** -....................... - -Please note that this operation may take hours depending on the amount of data in your system. This known issue is being tracked at https://github.com/IQSS/dataverse/issues/50 - -``curl http://localhost:8080/api/admin/index`` - -Reindex in Place -~~~~~~~~~~~~~~~~ - -An alternative to completely clearing the search index is to reindex in place. - -**Clear Index Timestamps** -.......................... - -``curl -X DELETE http://localhost:8080/api/admin/index/timestamps`` - -**Start or Continue Async Reindex** -................................... - -If indexing stops, this command should pick up where it left off based on which index timestamps have been set, which is why we start by clearing these timestamps above. These timestamps are stored in the ``dvobject`` database table. - -``curl http://localhost:8080/api/admin/index/continue`` - -Glassfish ---------- - -``server.log`` is the main place to look when you encounter problems. Hopefully an error message has been logged. If there's a stack trace, it may be of interest to developers, especially they can trace line numbers back to a tagged version. - -For debugging purposes, you may find it helpful to increase logging levels as mentioned in the :doc:`/developers/debugging` section of the Developer Guide. - -This guide has focused on using the command line to manage Glassfish but you might be interested in an admin GUI at http://localhost:4848 - -Monitoring ----------- - -In production you'll want to monitor the usual suspects such as CPU, memory, free disk space, etc. - -https://github.com/IQSS/dataverse/issues/2595 contains some information on enabling monitoring of Glassfish, which is disabled by default. - -There is a database table called ``actionlogrecord`` that captures events that may be of interest. See https://github.com/IQSS/dataverse/issues/2729 for more discussion around this table. - -Maintenance ------------ - -When you have scheduled down time for your production servers, we provide a :download:`sample maintenance page <../_static/installation/files/etc/maintenance/maintenance.xhtml>` for you to use. To download, right-click and select "Save Link As". - -The maintenance page is intended to be a static page served by Apache to provide users with a nicer, more informative experience when the site is unavailable. - -User Administration -------------------- - -There isn't much in the way of user administration tools built in to Dataverse. - -Confirm Email -+++++++++++++ - -Dataverse encourages builtin/local users to verify their email address upon signup or email change so that sysadmins can be assured that users can be contacted. - -The app will send a standard welcome email with a URL the user can click, which, when activated, will store a ``lastconfirmed`` timestamp in the ``authenticateduser`` table of the database. Any time this is "null" for a user (immediately after signup and/or changing of their Dataverse email address), their current email on file is considered to not be verified. The link that is sent expires after a time (the default is 24 hours), but this is configurable by a superuser via the ``:MinutesUntilConfirmEmailTokenExpires`` config option. - -Should users' URL token expire, they will see a "Verify Email" button on the account information page to send another URL. - -Sysadmins can determine which users have verified their email addresses by looking for the presence of the value ``emailLastConfirmed`` in the JSON output from listing users (see the "Admin" section of the :doc:`/api/native-api`). As mentioned in the :doc:`/user/account` section of the User Guide, the email addresses for Shibboleth users are re-confirmed on every login. - -Deleting an API Token -+++++++++++++++++++++ - -If an API token is compromised it should be deleted. Users can generate a new one for themselves as explained in the :doc:`/user/account` section of the User Guide, but you may want to preemptively delete tokens from the database. - -Using the API token 7ae33670-be21-491d-a244-008149856437 as an example: - -``delete from apitoken where tokenstring = '7ae33670-be21-491d-a244-008149856437';`` - -You should expect the output ``DELETE 1`` after issuing the command above. - diff --git a/doc/sphinx-guides/source/installation/config.rst b/doc/sphinx-guides/source/installation/config.rst index 4067595b2c1..40a22c7e42a 100644 --- a/doc/sphinx-guides/source/installation/config.rst +++ b/doc/sphinx-guides/source/installation/config.rst @@ -6,7 +6,7 @@ Now that you've successfully logged into Dataverse with a superuser account afte Settings within Dataverse itself are managed via JVM options or by manipulating values in the ``setting`` table directly or through API calls. Configuring Solr requires manipulating XML files. -Once you have finished securing and configuring your Dataverse installation, proceed to the :doc:`administration` section. Advanced configuration topics are covered in the :doc:`r-rapache-tworavens`, :doc:`shibboleth` and :doc:`oauth2` sections. +Once you have finished securing and configuring your Dataverse installation, you may proceed to the :doc:`/admin/index` for more information on the ongoing administration of a Dataverse installation. Advanced configuration topics are covered in the :doc:`r-rapache-tworavens`, :doc:`shibboleth` and :doc:`oauth2` sections. .. contents:: |toctitle| :local: @@ -315,12 +315,12 @@ If you are not fronting Glassfish with Apache you'll need to prevent Glassfish f Putting Your Dataverse Installation on the Map at dataverse.org +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -Congratulations! You've gone live! It's time to announce your new data respository to the world! You are also welcome to contact support@dataverse.org to have the Dataverse team add your installation to the map at http://dataverse.org . Thank you for installing Datavese! +Congratulations! You've gone live! It's time to announce your new data respository to the world! You are also welcome to contact support@dataverse.org to have the Dataverse team add your installation to the map at http://dataverse.org . Thank you for installing Dataverse! Administration of Your Dataverse Installation +++++++++++++++++++++++++++++++++++++++++++++ -Now that you're live you'll want to review the :doc:`/admin/index`. Please note that there is also an :doc:`administration` section of this Installation Guide that will be moved to the newer Admin Guide in the future. +Now that you're live you'll want to review the :doc:`/admin/index` for more information about the ongoing administration of a Dataverse installation. JVM Options ----------- @@ -679,6 +679,15 @@ For dynamically adding information to the top of every page. For example, "For t ``curl -X PUT -d "For testing only..." http://localhost:8080/api/admin/settings/:StatusMessageHeader`` +You can make the text clickable and include an additional message in a pop up by setting ``:StatusMessageText``. + +:StatusMessageText +++++++++++++++++++ + +After you've set ``:StatusMessageHeader`` you can also make it clickable to have it include text if a popup with this: + +``curl -X PUT -d "This appears in a popup." http://localhost:8080/api/admin/settings/:StatusMessageText`` + :MaxFileUploadSizeInBytes +++++++++++++++++++++++++ @@ -689,6 +698,13 @@ Notes: ``curl -X PUT -d 2147483648 http://localhost:8080/api/admin/settings/:MaxFileUploadSizeInBytes`` +:ZipDownloadLimit ++++++++++++++++++ + +For performance reasons, Dataverse will only create zip files on the fly up to 100 MB but the limit can be increased. Here's an example of raising the limit to 1 GB: + +``curl -X PUT -d 1000000000 http://localhost:8080/api/admin/settings/:ZipDownloadLimit`` + :TabularIngestSizeLimit +++++++++++++++++++++++ @@ -799,7 +815,7 @@ Allow for migration of non-conformant data (especially dates) from DVN 3.x to Da :MinutesUntilConfirmEmailTokenExpires +++++++++++++++++++++++++++++++++++++ -The duration in minutes before "Confirm Email" URLs expire. The default is 1440 minutes (24 hours). See also :doc:`/installation/administration`. +The duration in minutes before "Confirm Email" URLs expire. The default is 1440 minutes (24 hours). See also the :doc:`/admin/user-administration` section of our Admin Guide. :DefaultAuthProvider ++++++++++++++++++++ @@ -871,7 +887,7 @@ Set the base URL for the "Compute" button for a dataset. :CloudEnvironmentName +++++++++++++++++++++ -Set the base URL for the "Compute" button for a dataset. +Set the name of the cloud environment you've integrated with your Dataverse installation. ``curl -X PUT -d 'Massachusetts Open Cloud (MOC)' http://localhost:8080/api/admin/settings/:CloudEnvironmentName`` diff --git a/doc/sphinx-guides/source/installation/index.rst b/doc/sphinx-guides/source/installation/index.rst index 0223efcfaf2..0527803c806 100755 --- a/doc/sphinx-guides/source/installation/index.rst +++ b/doc/sphinx-guides/source/installation/index.rst @@ -15,7 +15,6 @@ Installation Guide prerequisites installation-main config - administration upgrading r-rapache-tworavens geoconnect diff --git a/doc/sphinx-guides/source/installation/prerequisites.rst b/doc/sphinx-guides/source/installation/prerequisites.rst index a570e5ab84e..1829c6396e8 100644 --- a/doc/sphinx-guides/source/installation/prerequisites.rst +++ b/doc/sphinx-guides/source/installation/prerequisites.rst @@ -78,6 +78,8 @@ Adjust this :download:`Glassfish init script <../_static/installation/files/etc/ It is not necessary to have Glassfish running before you execute the Dataverse installation script because it will start Glassfish for you. +Please note that you must run Glassfish in an English locale. If you are using something like ``LANG=de_DE.UTF-8``, ingest of tabular data will fail with the message "RoundRoutines:decimal separator no in right place". + PostgreSQL ---------- diff --git a/doc/sphinx-guides/source/installation/shibboleth.rst b/doc/sphinx-guides/source/installation/shibboleth.rst index 59562ba0696..a883cef2826 100644 --- a/doc/sphinx-guides/source/installation/shibboleth.rst +++ b/doc/sphinx-guides/source/installation/shibboleth.rst @@ -349,7 +349,7 @@ If you have more than one Glassfish server, you should use the same ``sp-cert.pe Debugging --------- -The :doc:`administration` section explains how to increase Glassfish logging levels. The relevant classes and packages are: +The :doc:`/admin/troubleshooting` section of the Admin Guide explains how to increase Glassfish logging levels. The relevant classes and packages are: - edu.harvard.iq.dataverse.Shib - edu.harvard.iq.dataverse.authorization.providers.shib diff --git a/src/main/java/Bundle.properties b/src/main/java/Bundle.properties index ab8e29b663b..9f8b7a1df6d 100755 --- a/src/main/java/Bundle.properties +++ b/src/main/java/Bundle.properties @@ -53,6 +53,8 @@ yes=Yes no=No previous=Previous next=Next +first=First +last=Last more=More... less=Less... select=Select... @@ -491,6 +493,38 @@ harvestserver.newSetDialog.success=Successfully created harvesting set "{0}". harvestserver.viewEditDialog.title=Edit Harvesting Set harvestserver.viewEditDialog.btn.save=Save Changes +#dashboard-users.xhtml +dashboard.card.users=Users +dashboard.card.users.header=Dashboard - User List +dashboard.card.users.super=Superusers +dashboard.card.users.manage=Manage Users +dashboard.card.users.message=List and manage users. +dashboard.list_users.searchTerm.watermark=Search these users... + +dashboard.list_users.tbl_header.userId=ID +dashboard.list_users.tbl_header.userIdentifier=Username +dashboard.list_users.tbl_header.name=Name +dashboard.list_users.tbl_header.lastName=Last Name +dashboard.list_users.tbl_header.firstName=First Name +dashboard.list_users.tbl_header.email=Email +dashboard.list_users.tbl_header.affiliation=Affiliation +dashboard.list_users.tbl_header.roles=Roles +dashboard.list_users.tbl_header.position=Position +dashboard.list_users.tbl_header.isSuperuser=Superuser +dashboard.list_users.tbl_header.authProviderFactoryAlias=Authentication +dashboard.list_users.tbl_header.createdTime=Created Time +dashboard.list_users.tbl_header.lastLoginTime=Last Login Time +dashboard.list_users.tbl_header.lastApiUseTime=Last API Use Time + + +dashboard.list_users.toggleSuperuser=Edit Superuser Status +dashboard.list_users.toggleSuperuser.confirmationText.add=Are you sure you want to enable superuser status for user {0}? +dashboard.list_users.toggleSuperuser.confirmationText.remove=Are you sure you want to disable superuser status for user {0}? +dashboard.list_users.toggleSuperuser.confirm=Continue + +dashboard.list_users.api.auth.invalid_apikey=The API key is invalid. +dashboard.list_users.api.auth.not_superuser=Forbidden. You must be a superuser. + #MailServiceBean.java notification.email.create.dataverse.subject={0}: Your dataverse has been created @@ -1630,3 +1664,12 @@ citationFrame.banner.message.here=here citationFrame.banner.closeIcon=Close this message, go to dataset citationFrame.banner.countdownMessage= This message will close in citationFrame.banner.countdownMessage.seconds=seconds + +# Friendly AuthenticationProvider names +authenticationProvider.name.builtin=Dataverse +authenticationProvider.name.null=(provider is unknown) +authenticationProvider.name.github=GitHub +authenticationProvider.name.google=Google +authenticationProvider.name.orcid=ORCiD +authenticationProvider.name.orcid-sandbox=ORCiD Sandbox +authenticationProvider.name.shib=Shibboleth \ No newline at end of file diff --git a/src/main/java/edu/harvard/iq/dataverse/DataFile.java b/src/main/java/edu/harvard/iq/dataverse/DataFile.java index d0b511111d8..b189a434008 100644 --- a/src/main/java/edu/harvard/iq/dataverse/DataFile.java +++ b/src/main/java/edu/harvard/iq/dataverse/DataFile.java @@ -831,12 +831,12 @@ public Long getPreviousDataFileId(){ return this.previousDataFileId; } - public String asPrettyJSON(){ + public String toPrettyJSON(){ return serializeAsJSON(true); } - public String asJSON(){ + public String toJSON(){ return serializeAsJSON(false); } diff --git a/src/main/java/edu/harvard/iq/dataverse/FileMetadata.java b/src/main/java/edu/harvard/iq/dataverse/FileMetadata.java index 615f6013225..8e75b89d22f 100644 --- a/src/main/java/edu/harvard/iq/dataverse/FileMetadata.java +++ b/src/main/java/edu/harvard/iq/dataverse/FileMetadata.java @@ -500,12 +500,12 @@ public int compare(FileMetadata o1, FileMetadata o2) { - public String asPrettyJSON(){ + public String toPrettyJSON(){ return serializeAsJSON(true); } - public String asJSON(){ + public String toJSON(){ return serializeAsJSON(false); } diff --git a/src/main/java/edu/harvard/iq/dataverse/MailServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/MailServiceBean.java index 5f37aeee11c..c3bf6adacac 100644 --- a/src/main/java/edu/harvard/iq/dataverse/MailServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/MailServiceBean.java @@ -102,6 +102,7 @@ public void sendMail(String host, String from, String to, String subject, String private Session session; public boolean sendSystemEmail(String to, String subject, String messageText) { + boolean sent = false; String rootDataverseName = dataverseService.findRootDataverse().getName(); String body = messageText + BundleUtil.getStringFromBundle("notification.email.closing", Arrays.asList(BrandingUtil.getInstallationBrandName(rootDataverseName))); @@ -180,7 +181,8 @@ public void sendMail(String from, String to, String subject, String messageText, } } - public Boolean sendNotificationEmail(UserNotification notification){ + public Boolean sendNotificationEmail(UserNotification notification){ + boolean retval = false; String emailAddress = getUserEmailAddress(notification); if (emailAddress != null){ diff --git a/src/main/java/edu/harvard/iq/dataverse/UserServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/UserServiceBean.java index f2d2fea395c..dd3ce4e5633 100644 --- a/src/main/java/edu/harvard/iq/dataverse/UserServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/UserServiceBean.java @@ -1,15 +1,23 @@ package edu.harvard.iq.dataverse; - import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; import edu.harvard.iq.dataverse.search.IndexServiceBean; +import edu.harvard.iq.dataverse.userdata.UserUtil; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.logging.Level; import java.sql.Timestamp; import java.util.Date; import java.util.logging.Logger; +import java.util.stream.Collectors; import javax.ejb.EJB; import javax.ejb.Stateless; import javax.inject.Named; import javax.persistence.EntityManager; import javax.persistence.PersistenceContext; +import javax.persistence.Query; +import org.apache.commons.lang.StringUtils; +import org.ocpsoft.common.util.Strings; @Stateless @Named @@ -42,7 +50,465 @@ public AuthenticatedUser save(AuthenticatedUser user) { return user; } + + /** + * Return the user information as a List of AuthenticatedUser objects -- easier to work with in the UI + * - With Role added as a transient field + * @param searchTerm + * @param sortKey + * @param resultLimit + * @param offset + * @return + */ + public List getAuthenticatedUserList(String searchTerm, String sortKey, Integer resultLimit, Integer offset){ + + if ((offset == null)||(offset < 0)){ + offset = 0; + } + + List userResults = getUserListCore(searchTerm, sortKey, resultLimit, offset); + + // Initialize empty list for AuthenticatedUser objects + // + List viewObjects = new ArrayList<>(); + + if (userResults == null){ + return viewObjects; + } + + // ------------------------------------------------- + // GATHER GIANT HASHMAP OF ALL { user identifier : [role, role, role] } + // ------------------------------------------------- + + + HashMap> roleLookup = retrieveRolesForUsers(userResults); + if (roleLookup == null){ + roleLookup = new HashMap<>(); + } + // 1st Loop : + // gather [ @user, .....] + // get the hashmap + + + // ------------------------------------------------- + // We have results, format them into AuthenticatedUser objects + // ------------------------------------------------- + int rowNum = offset++; // used for the rowNumber + String roleString; + for (Object[] userInfo : userResults) { + // GET ROLES FOR THIS USER FROM GIANT HASHMAP + rowNum++; + + //String roles = getUserRolesAsString((Integer) dbResultRow[0]); + roleString = ""; + List roleList = roleLookup.get("@" + (String)userInfo[1]); + if ((roleList != null)&&(!roleList.isEmpty())){ + roleString = roleList.stream().collect(Collectors.joining(", ")); + } + AuthenticatedUser singleUser = createAuthenticatedUserForView(userInfo, roleString, rowNum); + viewObjects.add(singleUser); + } + + + return viewObjects; + } + + private AuthenticatedUser createAuthenticatedUserForView (Object[] dbRowValues, String roles, int rowNum){ + AuthenticatedUser user = new AuthenticatedUser(); + user.setId(new Long((int)dbRowValues[0])); + user.setUserIdentifier((String)dbRowValues[1]); + user.setLastName(UserUtil.getStringOrNull(dbRowValues[2])); + user.setFirstName(UserUtil.getStringOrNull(dbRowValues[3])); + user.setEmail(UserUtil.getStringOrNull(dbRowValues[4])); + user.setAffiliation(UserUtil.getStringOrNull(dbRowValues[5])); + user.setSuperuser((Boolean)(dbRowValues[6])); + user.setPosition(UserUtil.getStringOrNull(dbRowValues[7])); + + user.setCreatedTime(UserUtil.getTimestampOrNull(dbRowValues[8])); + user.setLastLoginTime(UserUtil.getTimestampOrNull(dbRowValues[9])); + user.setLastApiUseTime(UserUtil.getTimestampOrNull(dbRowValues[10])); + + user.setAuthProviderId(UserUtil.getStringOrNull(dbRowValues[11])); + user.setAuthProviderFactoryAlias(UserUtil.getStringOrNull(dbRowValues[12])); + + user.setRoles(roles); + return user; + } + + /** + * Attempt to retrieve all the user roles in 1 query + * Consider putting limits on this -- e.g. no more than 1,000 user identifiers or something similar + * + * @param userIdentifierList + * @return + */ + private HashMap> retrieveRolesForUsers(List userObjectList){ + // Iterate through results, retrieving only the assignee identifiers + // Note: userInfo[1], the assigneeIdentifier, cannot be null in the database + // + List userIdentifierList = userObjectList.stream() + .map(userInfo -> (String)userInfo[1]) + .collect(Collectors.toList()) + ; + + List databaseIds = userObjectList.stream() + .map(userInfo -> (Integer)userInfo[0]) + .collect(Collectors.toList()); + + + if ((userIdentifierList==null)||(userIdentifierList.isEmpty())){ + return null; + } + + // ------------------------------------------------- + // Prepare a string to use within the SQL "a.assigneeidentifier IN (....)" clause + // + // Note: This is not ideal but .setParameter was failing with attempts using: + // + // Collection, List, String[] + // + // This appears to be due to the JDBC driver or Postgres. In this case SQL + // injection isn't possible b/c the list of assigneeidentifier strings comes + // from a previous query + // + // Add '@' to each identifier and delimit the list by "," + // ------------------------------------------------- + String identifierListString = userIdentifierList.stream() + .filter(x -> !Strings.isNullOrEmpty(x)) + .map(x -> "'@" + x + "'") + .collect(Collectors.joining(", ")); + + // ------------------------------------------------- + // Create/Run the query to find directly assigned roles + // ------------------------------------------------- + String qstr = "SELECT distinct a.assigneeidentifier,"; + qstr += " d.name"; + qstr += " FROM roleassignment a,"; + qstr += " dataverserole d"; + qstr += " WHERE d.id = a.role_id"; + qstr += " AND a.assigneeidentifier IN (" + identifierListString + ")"; + qstr += " ORDER by a.assigneeidentifier, d.name;"; + + Query nativeQuery = em.createNativeQuery(qstr); + + List dbRoleResults = nativeQuery.getResultList(); + if (dbRoleResults == null){ + return null; + } + + HashMap> userRoleLookup = new HashMap<>(); + + String userIdentifier; + String userRole; + for (Object[] dbResultRow : dbRoleResults) { + + userIdentifier = UserUtil.getStringOrNull(dbResultRow[0]); + userRole = UserUtil.getStringOrNull(dbResultRow[1]); + if ((userIdentifier != null)&&(userRole != null)){ // should never be null + + List userRoleList = userRoleLookup.getOrDefault(userIdentifier, new ArrayList()); + if (!userRoleList.contains(userRole)){ + userRoleList.add(userRole); + userRoleLookup.put(userIdentifier, userRoleList); + } + } + } + + // And now the roles assigned via groups: + + + // 1. One query for selecting all the groups to which these users may belong: + + HashMap> groupsLookup = new HashMap<>(); + + String idListString = StringUtils.join(databaseIds, ","); + + // A *RECURSIVE* native query, that finds all the groups that the specified + // users are part of, BOTH by direct inclusion, AND via parent groups: + + qstr = "WITH RECURSIVE group_user AS ((" + + " SELECT distinct g.groupalias, g.id, u.useridentifier" + + " FROM explicitgroup g, explicitgroup_authenticateduser e, authenticateduser u" + + " WHERE e.explicitgroup_id = g.id " + + " AND u.id IN (" + idListString + ")" + + " AND u.id = e.containedauthenticatedusers_id)" + + " UNION\n" + + " SELECT p.groupalias, p.id, c.useridentifier" + + " FROM group_user c, explicitgroup p, explicitgroup_explicitgroup e" + + " WHERE e.explicitgroup_id = p.id" + + " AND e.containedexplicitgroups_id = c.id)" + + "SELECT distinct groupalias, useridentifier FROM group_user;"; + + + //System.out.println("qstr: " + qstr); + + nativeQuery = em.createNativeQuery(qstr); + List groupResults = nativeQuery.getResultList(); + if (groupResults == null){ + return userRoleLookup; + } + + String groupIdentifiers = null; + + for (Object group[] : groupResults) { + String alias = UserUtil.getStringOrNull(group[0]); + String user = UserUtil.getStringOrNull(group[1]); + if (alias != null ) { + + alias = "&explicit/"+alias; + + if (groupIdentifiers == null) { + groupIdentifiers = "'"+alias+"'"; + } else { + groupIdentifiers += ", '"+alias+"'"; + } + + List groupUserList = groupsLookup.getOrDefault(alias, new ArrayList()); + if (!groupUserList.contains(user)){ + groupUserList.add(user); + groupsLookup.put(alias, groupUserList); + } + } + } + + // 2. And now we can make another lookup on the roleassignment table, using the list + // of the explicit group aliases we have just generated: + + if (groupIdentifiers == null) { + return userRoleLookup; + } + + qstr = "SELECT distinct a.assigneeidentifier,"; + qstr += " d.name"; + qstr += " FROM roleassignment a,"; + qstr += " dataverserole d"; + qstr += " WHERE d.id = a.role_id"; + qstr += " AND a.assigneeidentifier IN ("; + qstr += groupIdentifiers; + qstr += ") ORDER by a.assigneeidentifier, d.name;"; + + //System.out.println("qstr: " + qstr); + + nativeQuery = em.createNativeQuery(qstr); + + dbRoleResults = nativeQuery.getResultList(); + if (dbRoleResults == null){ + return userRoleLookup; + } + + + for (Object[] dbResultRow : dbRoleResults) { + + String groupIdentifier = UserUtil.getStringOrNull(dbResultRow[0]); + String groupRole = UserUtil.getStringOrNull(dbResultRow[1]); + if ((groupIdentifier != null)&&(groupRole != null)){ // should never be null + + List groupUserList = groupsLookup.get(groupIdentifier); + + if (groupUserList != null) { + + for (String groupUserIdentifier : groupUserList) { + groupUserIdentifier = "@" + groupUserIdentifier; + //System.out.println("Group user: "+groupUserIdentifier); + List userRoleList = userRoleLookup.getOrDefault(groupUserIdentifier, new ArrayList()); + if (!userRoleList.contains(groupRole)){ + //System.out.println("User Role: "+groupRole); + userRoleList.add(groupRole); + userRoleLookup.put(groupUserIdentifier, userRoleList); + } + } + } + } + } + + + return userRoleLookup; + } + + /** + * + * @param userId + * @return + */ + private String getUserRolesAsString(Integer userId) { + String retval = ""; + String userIdentifier = ""; + String qstr = "select useridentifier "; + qstr += " FROM authenticateduser"; + qstr += " WHERE id = " + userId.toString(); + qstr += ";"; + + Query nativeQuery = em.createNativeQuery(qstr); + + userIdentifier = '@' + (String) nativeQuery.getSingleResult(); + + qstr = " select distinct d.name from roleassignment a, dataverserole d"; + qstr += " where d.id = a.role_id and a.assigneeidentifier='" + userIdentifier + "'" + + " Order by d.name;"; + + nativeQuery = em.createNativeQuery(qstr); + + List roleList = nativeQuery.getResultList(); + + for (Object o : roleList) { + if (!retval.isEmpty()) { + retval += ", "; + } + retval += (String) o; + } + return retval; + } + + /** + * + * Run a native query, returning a List containing + * AuthenticatedUser information as well as information about the + * Authenticated Provider (e.g. builtin user, etc) + * + * @param searchTerm + * @param sortKey + * @param resultLimit + * @return + */ + private List getUserListCore(String searchTerm, String sortKey, Integer resultLimit, Integer offset) { + + if ((sortKey == null) || (sortKey.isEmpty())){ + sortKey = "u.username"; + }else{ + sortKey = "u." + sortKey; + } + + if ((resultLimit == null)||(resultLimit < 1)){ + resultLimit = 1; + } + + if ((searchTerm==null)||(searchTerm.isEmpty())){ + searchTerm = ""; + } + + if ((offset == null)||(offset < 0)){ + offset = 0; + } + + //Results of this query are used to build Authenticated User records: + + searchTerm = searchTerm.trim(); + + String sharedSearchClause = ""; + + if (!searchTerm.isEmpty()) { + sharedSearchClause = " AND " + getSharedSearchClause(); + } + + + String qstr = "SELECT u.id, u.useridentifier,"; + qstr += " u.lastname, u.firstname, u.email,"; + qstr += " u.affiliation, u.superuser,"; + qstr += " u.position,"; + qstr += " u.createdtime, u.lastlogintime, u.lastapiusetime, "; + qstr += " prov.id, prov.factoryalias"; + qstr += " FROM authenticateduser u,"; + qstr += " authenticateduserlookup prov_lookup,"; + qstr += " authenticationproviderrow prov"; + qstr += " WHERE"; + qstr += " u.id = prov_lookup.authenticateduser_id"; + qstr += " AND prov_lookup.authenticationproviderid = prov.id"; + qstr += sharedSearchClause; + qstr += " ORDER BY u.useridentifier"; + qstr += " LIMIT " + resultLimit; + qstr += " OFFSET " + offset; + qstr += ";"; + + logger.log(Level.FINE, "getUserCount: {0}", qstr); + + Query nativeQuery = em.createNativeQuery(qstr); + nativeQuery.setParameter("searchTerm", searchTerm + "%"); + + return nativeQuery.getResultList(); + + } + + /** + * The search clause needs to be consistent between the searches that: + * (1) get a user count + * (2) get a list of users + * + * @return + */ + private String getSharedSearchClause(){ + + String searchClause = " (u.useridentifier ILIKE #searchTerm"; + searchClause += " OR u.firstname ILIKE #searchTerm"; + searchClause += " OR u.lastname ILIKE #searchTerm"; + searchClause += " OR u.email ILIKE #searchTerm)"; + + return searchClause; + } + + + /** + * Return the number of superusers -- for the dashboard + * @return + */ + public Long getSuperUserCount() { + + String qstr = "SELECT count(au)"; + qstr += " FROM AuthenticatedUser au"; + qstr += " WHERE au.superuser = :superuserTrue"; + + Query query = em.createQuery(qstr); + query.setParameter("superuserTrue", true); + + return (Long)query.getSingleResult(); + } + + /** + * Return count of all users + * @return + */ + public Long getTotalUserCount(){ + + return getUserCount(""); + } + + /** + * + * @param searchTerm + * @return + */ + public Long getUserCount(String searchTerm) { + + if ((searchTerm==null)||(searchTerm.isEmpty())){ + searchTerm = ""; + } + searchTerm = searchTerm.trim(); + + + String sharedSearchClause = ""; + + if (!searchTerm.isEmpty()) { + sharedSearchClause = " AND " + getSharedSearchClause(); + } + + String qstr = "SELECT count(u.id)"; + qstr += " FROM authenticateduser u,"; + qstr += " authenticateduserlookup prov_lookup,"; + qstr += " authenticationproviderrow prov"; + qstr += " WHERE"; + qstr += " u.id = prov_lookup.authenticateduser_id"; + qstr += " AND prov_lookup.authenticationproviderid = prov.id"; + qstr += sharedSearchClause; + qstr += ";"; + + Query nativeQuery = em.createNativeQuery(qstr); + nativeQuery.setParameter("searchTerm", searchTerm + "%"); + + return (Long)nativeQuery.getSingleResult(); + + } + + public AuthenticatedUser updateLastLogin(AuthenticatedUser user) { //assumes that AuthenticatedUser user already exists user.setLastLoginTime(new Timestamp(new Date().getTime())); @@ -55,4 +521,5 @@ public AuthenticatedUser updateLastApiUseTime(AuthenticatedUser user) { user.setLastApiUseTime(new Timestamp(new Date().getTime())); return save(user); } + } diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Admin.java b/src/main/java/edu/harvard/iq/dataverse/api/Admin.java index b145cdca1d2..d63f2270453 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Admin.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Admin.java @@ -4,9 +4,12 @@ import edu.harvard.iq.dataverse.DataFile; import edu.harvard.iq.dataverse.Dataset; import edu.harvard.iq.dataverse.Dataverse; +import edu.harvard.iq.dataverse.DataverseSession; import edu.harvard.iq.dataverse.DvObject; import edu.harvard.iq.dataverse.EMailValidator; +import edu.harvard.iq.dataverse.UserServiceBean; import edu.harvard.iq.dataverse.actionlogging.ActionLogRecord; +import static edu.harvard.iq.dataverse.api.AbstractApiBean.error; import edu.harvard.iq.dataverse.api.dto.RoleDTO; import edu.harvard.iq.dataverse.authorization.AuthenticatedUserDisplayInfo; import edu.harvard.iq.dataverse.authorization.AuthenticationProvider; @@ -50,13 +53,17 @@ import javax.ws.rs.Produces; import javax.ws.rs.core.Response.Status; import java.util.List; -import static edu.harvard.iq.dataverse.api.AbstractApiBean.error; import edu.harvard.iq.dataverse.authorization.AuthTestDataServiceBean; import edu.harvard.iq.dataverse.authorization.RoleAssignee; import edu.harvard.iq.dataverse.authorization.UserRecordIdentifier; import edu.harvard.iq.dataverse.authorization.users.User; import edu.harvard.iq.dataverse.dataset.DatasetThumbnail; import edu.harvard.iq.dataverse.dataset.DatasetUtil; +import edu.harvard.iq.dataverse.userdata.UserListMaker; +import edu.harvard.iq.dataverse.userdata.UserListResult; +import edu.harvard.iq.dataverse.util.StringUtil; +import java.util.ResourceBundle; +import javax.inject.Inject; import javax.ws.rs.QueryParam; /** * Where the secure, setup API calls live. @@ -74,6 +81,16 @@ public class Admin extends AbstractApiBean { ShibServiceBean shibService; @EJB AuthTestDataServiceBean authTestDataService; + @EJB + UserServiceBean userService; + + // Make the session available + @Inject + DataverseSession session; + + public static final String listUsersPartialAPIPath = "list-users"; + public static final String listUsersFullAPIPath = "/api/admin/" + listUsersPartialAPIPath; + @Path("settings") @GET @@ -273,6 +290,7 @@ public Response publishDataverseAsCreator(@PathParam("id") long id) { } } + @Deprecated @GET @Path("authenticatedUsers") public Response listAuthenticatedUsers() { @@ -291,7 +309,39 @@ public Response listAuthenticatedUsers() { return ok(userArray); } + + @GET + @Path(listUsersPartialAPIPath) + @Produces({"application/json"}) + public Response filterAuthenticatedUsers(@QueryParam("searchTerm") String searchTerm, + @QueryParam("selectedPage") Integer selectedPage, + @QueryParam("itemsPerPage") Integer itemsPerPage + ) { + + User authUser; + try { + authUser = this.findUserOrDie(); + } catch (AbstractApiBean.WrappedResponse ex) { + return error(Response.Status.FORBIDDEN, + ResourceBundle.getBundle("Bundle").getString("dashboard.list_users.api.auth.invalid_apikey") + ); + } + + if (!authUser.isSuperuser()){ + return error(Response.Status.FORBIDDEN, + ResourceBundle.getBundle("Bundle").getString("dashboard.list_users.api.auth.not_superuser")); + } + + + UserListMaker userListMaker = new UserListMaker(userService); + + String sortKey = null; + UserListResult userListResult = userListMaker.runUserSearch(searchTerm, itemsPerPage, selectedPage, sortKey); + return ok(userListResult.toJSON()); + } + + /** * @todo Make this support creation of BuiltInUsers. * diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationProvider.java b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationProvider.java index cdf38141dc3..ec989317758 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationProvider.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationProvider.java @@ -1,6 +1,7 @@ package edu.harvard.iq.dataverse.authorization; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import edu.harvard.iq.dataverse.util.BundleUtil; /** * Objects that can authenticate users. The authentication process yields a unique @@ -10,6 +11,13 @@ * can be queried using the {@code isXXXAllowed()} methods. If an implementation returns {@code true} * from one of these methods, it has to implement the matching methods. * + * Note: If you are adding an implementation of this interface, please add a "friendly" name to the bundles. + * Example: ShibAuthenticationProvider implements this interface. + * (a) ShibAuthenticationProvider.getID() returns "ship" + * (b) Construct bundle name using String id "shib" from (a): + * "authenticationProvider.name." + "ship" -> + * (c) Bundle.properties entry: "authenticationProvider.name.shib=Shibboleth" + * * {@code AuthenticationPrvider}s are normally registered at startup in {@link AuthenticationServiceBean#startup()}. * * @author michael @@ -17,7 +25,7 @@ public interface AuthenticationProvider { String getId(); - + AuthenticationProviderDisplayInfo getInfo(); default boolean isPasswordUpdateAllowed() { return false; }; @@ -35,12 +43,16 @@ public interface AuthenticationProvider { default String getPersistentIdUrlPrefix() { return null; }; default String getLogo() { return null; }; + + /** * Some providers (e.g organizational ones) provide verified email addresses. * @return {@code true} if we can treat email addresses coming from this provider as verified, {@code false} otherwise. */ default boolean isEmailVerified() { return false; }; + + /** * Updates the password of the user whose id is passed. * @param userIdInProvider User id in the provider. NOT the {@link AuthenticatedUser#id}, which is internal to the installation. @@ -80,4 +92,37 @@ default void updateUserInfo( String userIdInProvider, AuthenticatedUserDisplayIn default void deleteUser( String userIdInProvider ) { throw new UnsupportedOperationException(this.toString() + " does not implement account deletions"); } + + + /** + * Given the AuthenticationProvider id, return the friendly name + * of the AuthenticationProvider as defined in the bundle + * + * If no name is defined, return the id itself + * + * @param authProviderId + * @return + */ + public static String getFriendlyName(String authProviderId){ + if (authProviderId == null){ + return BundleUtil.getStringFromBundle("authenticationProvider.name.null"); + } + + String friendlyName = BundleUtil.getStringFromBundle("authenticationProvider.name." + authProviderId); + if (friendlyName == null){ + return authProviderId; + } + return friendlyName; + } + + /** + * Given the AuthenticationProvider id, + * return the friendly name using the static method + */ + default String getFriendlyName(){ + // call static method + return BundleUtil.getStringFromBundle("authentication.human_readable." + this.getId()); + } + } + diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/users/AuthenticatedUser.java b/src/main/java/edu/harvard/iq/dataverse/authorization/users/AuthenticatedUser.java index f761b58f2a9..d3427670a0e 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/users/AuthenticatedUser.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/users/AuthenticatedUser.java @@ -4,14 +4,17 @@ import edu.harvard.iq.dataverse.ValidateEmail; import edu.harvard.iq.dataverse.authorization.AuthenticatedUserDisplayInfo; import edu.harvard.iq.dataverse.authorization.AuthenticatedUserLookup; -import edu.harvard.iq.dataverse.authorization.providers.builtin.BuiltinAuthenticationProvider; +import edu.harvard.iq.dataverse.userdata.UserUtil; import edu.harvard.iq.dataverse.authorization.providers.oauth2.impl.OrcidOAuth2AP; +import edu.harvard.iq.dataverse.util.BundleUtil; import static edu.harvard.iq.dataverse.util.StringUtil.nonEmpty; +import edu.harvard.iq.dataverse.util.json.NullSafeJsonBuilder; import java.io.Serializable; import java.sql.Timestamp; -import java.util.Date; import java.util.List; import java.util.Objects; +import javax.json.Json; +import javax.json.JsonObjectBuilder; import javax.persistence.CascadeType; import javax.persistence.Column; import javax.persistence.Entity; @@ -26,6 +29,15 @@ import javax.validation.constraints.NotNull; import org.hibernate.validator.constraints.NotBlank; +/** + * When adding an attribute to this class, be sure to update the following: + * + * (1) AuthenticatedUser.toJSON() - within this class (REQUIRED) + * (2) UserServiceBean.getUserListCore() - native SQL query + * (3) UserServiceBean.createAuthenticatedUserForView() - add values to a detached AuthenticatedUser object + * + * @author rmp553 + */ @NamedQueries({ @NamedQuery( name="AuthenticatedUser.findAll", query="select au from AuthenticatedUser au"), @@ -141,6 +153,45 @@ public void applyDisplayInfo( AuthenticatedUserDisplayInfo inf ) { setPosition( inf.getPosition()); } } + + + //For User List Admin dashboard + @Transient + private String roles; + + public String getRoles() { + return roles; + } + + public void setRoles(String roles) { + this.roles = roles; + } + + //For User List Admin dashboard - AuthenticatedProviderId + @Transient + private String authProviderId; + + public String getAuthProviderId() { + return authProviderId; + } + + public void setAuthProviderId(String authProviderId) { + this.authProviderId = authProviderId; + } + + + @Transient + private String authProviderFactoryAlias; + + public String getAuthProviderFactoryAlias() { + return authProviderFactoryAlias; + } + + public void setAuthProviderFactoryAlias(String authProviderFactoryAlias) { + this.authProviderFactoryAlias = authProviderFactoryAlias; + } + + @Override public boolean isAuthenticated() { return true; } @@ -258,6 +309,59 @@ public void setShibIdentityProvider(String shibIdentityProvider) { this.shibIdentityProvider = shibIdentityProvider; } + public JsonObjectBuilder toJson() { + //JsonObjectBuilder authenicatedUserJson = Json.createObjectBuilder(); + + NullSafeJsonBuilder authenicatedUserJson = NullSafeJsonBuilder.jsonObjectBuilder(); + + authenicatedUserJson.add("id", this.id); + authenicatedUserJson.add("userIdentifier", this.userIdentifier); + authenicatedUserJson.add("lastName", this.lastName); + authenicatedUserJson.add("firstName", this.firstName); + authenicatedUserJson.add("email", this.email); + authenicatedUserJson.add("affiliation", UserUtil.getStringOrNull(this.affiliation)); + authenicatedUserJson.add("position", UserUtil.getStringOrNull(this.position)); + authenicatedUserJson.add("isSuperuser", this.superuser); + + authenicatedUserJson.add("authenticationProvider", this.authProviderFactoryAlias); + authenicatedUserJson.add("roles", UserUtil.getStringOrNull(this.roles)); + + authenicatedUserJson.add("createdTime", UserUtil.getTimestampStringOrNull(this.createdTime)); + authenicatedUserJson.add("lastLoginTime", UserUtil.getTimestampStringOrNull(this.lastLoginTime)); + authenicatedUserJson.add("lastApiUseTime", UserUtil.getTimestampStringOrNull(this.lastApiUseTime)); + + return authenicatedUserJson; + } + + /** + * May be used for translating API field names. + * + * Should match order of "toJson()" method + * + * @return + */ + public static JsonObjectBuilder getBundleStrings(){ + + return Json.createObjectBuilder() + .add("userId", BundleUtil.getStringFromBundle("dashboard.list_users.tbl_header.userId")) + .add("userIdentifier", BundleUtil.getStringFromBundle("dashboard.list_users.tbl_header.userIdentifier")) + .add("lastName", BundleUtil.getStringFromBundle("dashboard.list_users.tbl_header.lastName")) + .add("firstName", BundleUtil.getStringFromBundle("dashboard.list_users.tbl_header.firstName")) + .add("email", BundleUtil.getStringFromBundle("dashboard.list_users.tbl_header.email")) + .add("affiliation", BundleUtil.getStringFromBundle("dashboard.list_users.tbl_header.affiliation")) + .add("position", BundleUtil.getStringFromBundle("dashboard.list_users.tbl_header.position")) + .add("isSuperuser", BundleUtil.getStringFromBundle("dashboard.list_users.tbl_header.isSuperuser")) + + .add("authenticationProvider", BundleUtil.getStringFromBundle("dashboard.list_users.tbl_header.authProviderFactoryAlias")) + .add("roles", BundleUtil.getStringFromBundle("dashboard.list_users.tbl_header.roles")) + + .add("createdTime", BundleUtil.getStringFromBundle("dashboard.list_users.tbl_header.createdTime")) + .add("lastLoginTime", BundleUtil.getStringFromBundle("dashboard.list_users.tbl_header.lastLoginTime")) + .add("lastApiUseTime", BundleUtil.getStringFromBundle("dashboard.list_users.tbl_header.lastApiUseTime")) + ; + + } + @Override public String toString() { return "[AuthenticatedUser identifier:" + getIdentifier() + "]"; diff --git a/src/main/java/edu/harvard/iq/dataverse/dashboard/DashboardUsersPage.java b/src/main/java/edu/harvard/iq/dataverse/dashboard/DashboardUsersPage.java new file mode 100644 index 00000000000..6b6894634e4 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/dashboard/DashboardUsersPage.java @@ -0,0 +1,223 @@ +package edu.harvard.iq.dataverse.dashboard; + +import edu.harvard.iq.dataverse.DataverseRequestServiceBean; +import edu.harvard.iq.dataverse.DataverseSession; +import edu.harvard.iq.dataverse.EjbDataverseEngine; +import edu.harvard.iq.dataverse.PermissionsWrapper; +import edu.harvard.iq.dataverse.UserServiceBean; +import edu.harvard.iq.dataverse.api.Admin; +import edu.harvard.iq.dataverse.authorization.AuthenticationProvider; +import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean; +import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import edu.harvard.iq.dataverse.engine.command.impl.GrantSuperuserStatusCommand; +import edu.harvard.iq.dataverse.engine.command.impl.RevokeSuperuserStatusCommand; +import edu.harvard.iq.dataverse.mydata.Pager; +import edu.harvard.iq.dataverse.userdata.UserListMaker; +import edu.harvard.iq.dataverse.userdata.UserListResult; +import java.text.NumberFormat; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.ejb.EJB; +import javax.faces.view.ViewScoped; +import javax.inject.Inject; +import javax.inject.Named; + +@ViewScoped +@Named("DashboardUsersPage") +public class DashboardUsersPage implements java.io.Serializable { + + @EJB + AuthenticationServiceBean authenticationService; + @EJB + UserServiceBean userService; + @Inject + DataverseSession session; + @Inject + PermissionsWrapper permissionsWrapper; + @EJB + EjbDataverseEngine commandEngine; + @Inject + DataverseRequestServiceBean dvRequestService; + + private static final Logger logger = Logger.getLogger(DashboardUsersPage.class.getCanonicalName()); + + private AuthenticatedUser authUser = null; + private Integer selectedPage = 1; + private UserListMaker userListMaker = null; + + private Pager pager; + private List userList; + + private String searchTerm; + + public String init() { + + if ((session.getUser() != null) && (session.getUser().isAuthenticated()) && (session.getUser().isSuperuser())) { + authUser = (AuthenticatedUser) session.getUser(); + userListMaker = new UserListMaker(userService); + runUserSearch(); + } else { + return permissionsWrapper.notAuthorized(); + // redirect to login OR give some type ‘you must be logged in message' + } + + return null; + } + + public boolean runUserSearchWithPage(Integer pageNumber){ + System.err.println("runUserSearchWithPage"); + setSelectedPage(pageNumber); + runUserSearch(); + return true; + } + + public boolean runUserSearch(){ + + logger.fine("Run the search!"); + + + /** + * (1) Determine the number of users returned by the count + */ + UserListResult userListResult = userListMaker.runUserSearch(searchTerm, UserListMaker.ITEMS_PER_PAGE, getSelectedPage(), null); + if (userListResult==null){ + try { + throw new Exception("userListResult should not be null!"); + } catch (Exception ex) { + Logger.getLogger(DashboardUsersPage.class.getName()).log(Level.SEVERE, null, ex); + } + } + setSelectedPage(userListResult.getSelectedPageNumber()); + + this.userList = userListResult.getUserList(); + this.pager = userListResult.getPager(); + + return true; + + } + + + + public String getListUsersAPIPath() { + //return "ok"; + return Admin.listUsersFullAPIPath; + } + + /** + * Number of total users + * @return + */ + public String getUserCount() { + + return NumberFormat.getInstance().format(userService.getTotalUserCount()); + } + + /** + * Number of total Superusers + * @return + */ + public Long getSuperUserCount() { + + return userService.getSuperUserCount(); + } + + public List getUserList() { + return this.userList; + } + + /** + * Pager for when user list exceeds the number of display rows + * (default: UserListMaker.ITEMS_PER_PAGE) + * + * @return + */ + public Pager getPager() { + return this.pager; + } + + public void setSelectedPage(Integer pgNum){ + if ((pgNum == null)||(pgNum < 1)){ + this.selectedPage = 1; + } + selectedPage = pgNum; + } + + public Integer getSelectedPage(){ + if ((selectedPage == null)||(selectedPage < 1)){ + setSelectedPage(null); + } + return selectedPage; + } + + public String getSearchTerm() { + return searchTerm; + } + + public void setSearchTerm(String searchTerm) { + this.searchTerm = searchTerm; + } + + /* + Methods for toggling the supeuser status of a selected user. + Our normal two step approach is used: first showing the "are you sure?" + popup, then finalizing the toggled value. + */ + + AuthenticatedUser selectedUserDetached = null; // Note: This is NOT the persisted object!!!! Don't try to save it, etc. + AuthenticatedUser selectedUserPersistent = null; // This is called on the fly and updated + + public void setSelectedUserDetached(AuthenticatedUser user) { + this.selectedUserDetached = user; + } + + public AuthenticatedUser getSelectedUserDetached() { + return this.selectedUserDetached; + } + + + public void setUserToToggleSuperuserStatus(AuthenticatedUser user) { + selectedUserDetached = user; + } + + public void saveSuperuserStatus() { + + // Retrieve the persistent version for saving to db + logger.fine("Get persisent AuthenticatedUser for id: " + selectedUserDetached.getId()); + selectedUserPersistent = userService.find(selectedUserDetached.getId()); + + if (selectedUserPersistent != null) { + logger.fine("Toggling user's " + selectedUserDetached.getIdentifier() + " superuser status; (current status: " + selectedUserDetached.isSuperuser() + ")"); + logger.fine("Attempting to save user " + selectedUserDetached.getIdentifier()); + + logger.fine("selectedUserPersistent info: " + selectedUserPersistent.getId() + " set to: " + selectedUserDetached.isSuperuser()); + selectedUserPersistent.setSuperuser(selectedUserDetached.isSuperuser()); + + // Using the new commands for granting and revoking the superuser status: + try { + if (!selectedUserPersistent.isSuperuser()) { + // We are revoking the status: + commandEngine.submit(new RevokeSuperuserStatusCommand(selectedUserPersistent, dvRequestService.getDataverseRequest())); + } else { + // granting the status: + commandEngine.submit(new GrantSuperuserStatusCommand(selectedUserPersistent, dvRequestService.getDataverseRequest())); + } + } catch (Exception ex) { + logger.warning("Failed to permanently toggle the superuser status for user " + selectedUserDetached.getIdentifier() + ": " + ex.getMessage()); + } + } else { + logger.warning("selectedUserPersistent is null. AuthenticatedUser not found for id: " + selectedUserDetached.getId()); + } + + } + + public void cancelSuperuserStatusChange(){ + selectedUserDetached.setSuperuser(!selectedUserDetached.isSuperuser()); + selectedUserPersistent = null; + } + + public String getAuthProviderFriendlyName(String authProviderId){ + + return AuthenticationProvider.getFriendlyName(authProviderId); + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/GrantSuperuserStatusCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/GrantSuperuserStatusCommand.java new file mode 100644 index 00000000000..dc2f0439f3f --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/GrantSuperuserStatusCommand.java @@ -0,0 +1,51 @@ +/* + * To change this license header, choose License Headers in Project Properties. + * To change this template file, choose Tools | Templates + * and open the template in the editor. + */ +package edu.harvard.iq.dataverse.engine.command.impl; + +import edu.harvard.iq.dataverse.Dataset; +import edu.harvard.iq.dataverse.IdServiceBean; +import edu.harvard.iq.dataverse.authorization.Permission; +import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import edu.harvard.iq.dataverse.engine.command.AbstractVoidCommand; +import edu.harvard.iq.dataverse.engine.command.CommandContext; +import edu.harvard.iq.dataverse.engine.command.DataverseRequest; +import edu.harvard.iq.dataverse.engine.command.RequiredPermissions; +import edu.harvard.iq.dataverse.engine.command.exception.CommandException; +import edu.harvard.iq.dataverse.engine.command.exception.PermissionException; + +/** + * + * @author Leonid Andreev + */ +// the permission annotation is open, since this is a superuser-only command - +// and that's enforced in the command body: +@RequiredPermissions({}) +public class GrantSuperuserStatusCommand extends AbstractVoidCommand { + + private final AuthenticatedUser targetUser; + + public GrantSuperuserStatusCommand (AuthenticatedUser targetUser, DataverseRequest aRequest) { + super(aRequest, (Dataset)null); + this.targetUser = targetUser; + } + + @Override + protected void executeImpl(CommandContext ctxt) throws CommandException { + + if (!(getUser() instanceof AuthenticatedUser) || !getUser().isSuperuser()) { + throw new PermissionException("Revoke Superuser status command can only be called by superusers.", + this, null, null); + } + + try { + targetUser.setSuperuser(true); + ctxt.em().merge(targetUser); + ctxt.em().flush(); + } catch (Exception e) { + throw new CommandException("Failed to grant the superuser status to user "+targetUser.getIdentifier(), this); + } + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RevokeSuperuserStatusCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RevokeSuperuserStatusCommand.java new file mode 100644 index 00000000000..47c5e3c0562 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/RevokeSuperuserStatusCommand.java @@ -0,0 +1,52 @@ +/* + * To change this license header, choose License Headers in Project Properties. + * To change this template file, choose Tools | Templates + * and open the template in the editor. + */ +package edu.harvard.iq.dataverse.engine.command.impl; + +import edu.harvard.iq.dataverse.Dataset; +import edu.harvard.iq.dataverse.IdServiceBean; +import edu.harvard.iq.dataverse.authorization.Permission; +import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import edu.harvard.iq.dataverse.engine.command.AbstractVoidCommand; +import edu.harvard.iq.dataverse.engine.command.CommandContext; +import edu.harvard.iq.dataverse.engine.command.DataverseRequest; +import edu.harvard.iq.dataverse.engine.command.RequiredPermissions; +import edu.harvard.iq.dataverse.engine.command.exception.CommandException; +import edu.harvard.iq.dataverse.engine.command.exception.PermissionException; + +/** + * + * @author Leonid Andreev + */ +// the permission annotation is open, since this is a superuser-only command - +// and that's enforced in the command body: +@RequiredPermissions({}) +public class RevokeSuperuserStatusCommand extends AbstractVoidCommand { + + private final AuthenticatedUser targetUser; + + public RevokeSuperuserStatusCommand (AuthenticatedUser targetUser, DataverseRequest aRequest) { + super(aRequest, (Dataset)null); + this.targetUser = targetUser; + } + + @Override + protected void executeImpl(CommandContext ctxt) throws CommandException { + + if (!(getUser() instanceof AuthenticatedUser) || !getUser().isSuperuser()) { + throw new PermissionException("Revoke Superuser status command can only be called by superusers.", + this, null, null); + } + + try { + targetUser.setSuperuser(false); + ctxt.em().merge(targetUser); + ctxt.em().flush(); + } catch (Exception e) { + throw new CommandException("Failed to revoke the superuser status for user "+targetUser.getIdentifier(), this); + } + } + +} diff --git a/src/main/java/edu/harvard/iq/dataverse/mydata/DataRetrieverAPI.java b/src/main/java/edu/harvard/iq/dataverse/mydata/DataRetrieverAPI.java index b9efe945333..c369c1f52e0 100644 --- a/src/main/java/edu/harvard/iq/dataverse/mydata/DataRetrieverAPI.java +++ b/src/main/java/edu/harvard/iq/dataverse/mydata/DataRetrieverAPI.java @@ -452,7 +452,7 @@ public String retrieveMyDataAsJsonString(@QueryParam("dvobject_types") List userList = userService.getAuthenticatedUserList(searchTerm, sortKey, itemsPerPage, offset); + if (userList ==null){ + pager = new Pager(0, itemsPerPage, selectedPage); + return new UserListResult(searchTerm, pager, null); + } + + pager = new Pager(userCount.intValue(), itemsPerPage, selectedPage); + + return new UserListResult(searchTerm, pager, userList); + + } + + + public OffsetPageValues getOffset(Long userCount, Integer selectedPage, Integer itemsPerPage){ + + if (userCount == null){ + return new OffsetPageValues(DEFAULT_OFFSET, 0); + } + + if (itemsPerPage == null){ + itemsPerPage = ITEMS_PER_PAGE; + } + if ((selectedPage == null)||(selectedPage < 1)){ + selectedPage = 1; + } + + int offset = (selectedPage - 1) * itemsPerPage; + if (offset > userCount){ + offset = DEFAULT_OFFSET; + selectedPage = 1; + } + + return new OffsetPageValues(offset, selectedPage); + + } + + + + public boolean hasError(){ + return this.errorFound; + } + + public String getErrorMessage(){ + return this.errorMessage; + } + + private void addErrorMessage(String errMsg){ + this.errorFound = true; + this.errorMessage = errMsg; + } + + private void msg(String s){ + System.out.println(s); + } + + private void msgt(String s){ + msg("-------------------------------"); + msg(s); + msg("-------------------------------"); + } + +} diff --git a/src/main/java/edu/harvard/iq/dataverse/userdata/UserListResult.java b/src/main/java/edu/harvard/iq/dataverse/userdata/UserListResult.java new file mode 100644 index 00000000000..07937638607 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/userdata/UserListResult.java @@ -0,0 +1,212 @@ +/* + * To change this license header, choose License Headers in Project Properties. + * To change this template file, choose Tools | Templates + * and open the template in the editor. + */ +package edu.harvard.iq.dataverse.userdata; + +import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import edu.harvard.iq.dataverse.mydata.Pager; +import edu.harvard.iq.dataverse.util.BundleUtil; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.json.Json; +import javax.json.JsonArrayBuilder; +import javax.json.JsonObjectBuilder; + +/** + * + * @author rmp553 + */ +public class UserListResult { + + private static final Logger logger = Logger.getLogger(UserListResult.class.getName()); + + private String searchTerm; + + private Pager pager; + private List userList; + + private boolean success; + + private String errorMessage; + + + public UserListResult(String searchTerm, Pager pager, List userList){ + + + if (searchTerm == null){ + searchTerm = ""; + } + this.searchTerm = searchTerm; + + this.pager = pager; + if (this.pager==null){ + logger.severe("Pager should never be null!"); + } + + this.userList = userList; + if (this.userList == null){ + this.userList = new ArrayList<>(); // new empty list + } + + } + + public Integer getSelectedPageNumber(){ + + if (pager == null){ + return 1; + } + return pager.getSelectedPageNumber(); + } + + /** + * Set searchTerm + * @param searchTerm + */ + public void setSearchTerm(String searchTerm){ + this.searchTerm = searchTerm; + } + + /** + * Get for searchTerm + * @return String + */ + public String getSearchTerm(){ + return this.searchTerm; + } + + + /** + * Set pager + * @param pager + */ + public void setPager(Pager pager){ + this.pager = pager; + } + + /** + * Get for pager + * @return Pager + */ + public Pager getPager(){ + return this.pager; + } + + + /** + * Set userList + * @param userList + */ + public void setUserList(List userList){ + this.userList = userList; + } + + /** + * Get for userList + * @return List + */ + public List getUserList(){ + return this.userList; + } + + + /** + * Set success + * @param success + */ + public void setSuccess(boolean success){ + this.success = success; + } + + /** + * Get for success + * @return boolean + */ + public boolean getSuccess(){ + return this.success; + } + + + + /** + * Set errorMessage + * @param errorMessage + */ + public void setErrorMessage(String errorMessage){ + this.errorMessage = errorMessage; + } + + /** + * Get for errorMessage + * @return String + */ + public String getErrorMessage(){ + return this.errorMessage; + } + + + /** + * TO DO! + * Return this object as a JsonObjectBuilder object + * + * @return + */ + public JsonObjectBuilder toJSON(){ + + if (userList.isEmpty()){ + return getNoResultsJSON(); + } + if (pager==null){ + logger.severe("Pager should never be null!"); + return getNoResultsJSON(); + + } + + JsonObjectBuilder jsonOverallData = Json.createObjectBuilder(); + jsonOverallData.add("userCount", pager.getNumResults()) + .add("selectedPage", pager.getSelectedPageNumber()) + .add("pagination", pager.asJsonObjectBuilder()) + .add("bundleStrings", AuthenticatedUser.getBundleStrings()) + .add("users", getUsersAsJSONArray()) + ; + return jsonOverallData; + } + + + + private JsonArrayBuilder getUsersAsJSONArray(){ + + // ------------------------------------------------- + // No results..... Return count of 0 and empty array + // ------------------------------------------------- + if ((userList==null)||(userList.isEmpty())){ + return Json.createArrayBuilder(); // return an empty array + } + + // ------------------------------------------------- + // We have results, format them into a JSON object + // ------------------------------------------------- + JsonArrayBuilder jsonUserListArray = Json.createArrayBuilder(); + + for (AuthenticatedUser oneUser : userList) { + jsonUserListArray.add(oneUser.toJson()); + } + return jsonUserListArray; + } + + + private JsonObjectBuilder getNoResultsJSON(){ + + return Json.createObjectBuilder() + .add("userCount", 0) + .add("selectedPage", 1) + .add("bundleStrings", AuthenticatedUser.getBundleStrings()) + .add("users", Json.createArrayBuilder()); // empty array + } + + + +} diff --git a/src/main/java/edu/harvard/iq/dataverse/userdata/UserUtil.java b/src/main/java/edu/harvard/iq/dataverse/userdata/UserUtil.java new file mode 100644 index 00000000000..1ec17ac5928 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/userdata/UserUtil.java @@ -0,0 +1,69 @@ +/* + * To change this license header, choose License Headers in Project Properties. + * To change this template file, choose Tools | Templates + * and open the template in the editor. + */ +package edu.harvard.iq.dataverse.userdata; + +import java.sql.Timestamp; + +/** + * + * @author rmp553 + */ +public class UserUtil { + + + /** + * Convenience method to format dbResult + * @param dbResult + * @return + */ + public static String getStringOrNull(Object dbResult){ + + if (dbResult == null){ + return null; + } + return (String)dbResult; + } + + /** + * Convenience method to format dbResult + * @param dbResult + * @return + */ + public static String getStringOrBlankForNull(Object dbResult){ + + if (dbResult == null){ + return ""; + } + return (String)dbResult; + } + + /** + * Convenience method to format dbResult + * @param dbResult + * @return + */ + public static String getTimestampStringOrNull(Object dbResult){ + + if (dbResult == null){ + return null; + } + return ((Timestamp)dbResult).toString(); + } + + /** + * Convenience method to format dbResult + * @param dbResult + * @return + */ + public static Timestamp getTimestampOrNull(Object dbResult){ + + if (dbResult == null){ + return null; + } + return (Timestamp)dbResult; + } + +} diff --git a/src/main/resources/META-INF/persistence.xml b/src/main/resources/META-INF/persistence.xml index ecd0c0ba787..9303aa98ea4 100644 --- a/src/main/resources/META-INF/persistence.xml +++ b/src/main/resources/META-INF/persistence.xml @@ -16,7 +16,7 @@ - + +
+ +

+ + + + + +

+
+
+ +
+ + \ No newline at end of file diff --git a/src/main/webapp/dashboard-users.xhtml b/src/main/webapp/dashboard-users.xhtml new file mode 100644 index 00000000000..c3b124f21c4 --- /dev/null +++ b/src/main/webapp/dashboard-users.xhtml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + + + + #{bundle['dataverse.search.btn.find']} + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+ + + + + + +

+
+ + +
+
+
+
+
+ +
+ diff --git a/src/main/webapp/dashboard.xhtml b/src/main/webapp/dashboard.xhtml index 69af5b4a53b..e6144dc2f22 100644 --- a/src/main/webapp/dashboard.xhtml +++ b/src/main/webapp/dashboard.xhtml @@ -102,6 +102,28 @@ +
+
+

#{bundle['dashboard.card.users']}

+
+
+ +

#{bundle['dashboard.card.users']}

+
+
+ +

#{bundle['dashboard.card.users.super']}

+
+
+ +
+
diff --git a/src/main/webapp/mydata_templates/pagination.html b/src/main/webapp/mydata_templates/pagination.html index 42900b4b0fe..3e97073afb9 100644 --- a/src/main/webapp/mydata_templates/pagination.html +++ b/src/main/webapp/mydata_templates/pagination.html @@ -7,15 +7,16 @@

{% if numResults > 1 %} - Found: {{ numResults }} results + Found: {{ numResultsString }} results {% elif numResults == 1 %} Found: 1 result {% endif %} {% if endCardNumber > startCardNumber %} - Displaying: {{ startCardNumber }} to {{ endCardNumber }} +
Displaying: {{ startCardNumberString }} to {{ endCardNumberString }} {% endif %} +
{% if isNecessary %} diff --git a/src/test/java/edu/harvard/iq/dataverse/api/AdminIT.java b/src/test/java/edu/harvard/iq/dataverse/api/AdminIT.java index 4367711f5f5..f7686d90a63 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/AdminIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/AdminIT.java @@ -3,15 +3,19 @@ import com.jayway.restassured.RestAssured; import com.jayway.restassured.path.json.JsonPath; import com.jayway.restassured.response.Response; +import static edu.harvard.iq.dataverse.api.UtilIT.getRandomString; import edu.harvard.iq.dataverse.authorization.providers.builtin.BuiltinAuthenticationProvider; import edu.harvard.iq.dataverse.authorization.providers.oauth2.impl.GitHubOAuth2AP; import edu.harvard.iq.dataverse.authorization.providers.oauth2.impl.OrcidOAuth2AP; +import java.util.ArrayList; +import java.util.List; import static javax.ws.rs.core.Response.Status.FORBIDDEN; import static javax.ws.rs.core.Response.Status.OK; import static javax.ws.rs.core.Response.Status.BAD_REQUEST; import org.junit.Test; import org.junit.BeforeClass; import java.util.UUID; +import javax.validation.constraints.AssertTrue; import static javax.ws.rs.core.Response.Status.CREATED; import static org.hamcrest.CoreMatchers.equalTo; import static org.hamcrest.CoreMatchers.notNullValue; @@ -31,13 +35,14 @@ public void testListAuthenticatedUsers() throws Exception { anon.then().assertThat().statusCode(FORBIDDEN.getStatusCode()); Response createNonSuperuser = UtilIT.createRandomUser(); + String nonSuperuserUsername = UtilIT.getUsernameFromResponse(createNonSuperuser); String nonSuperuserApiToken = UtilIT.getApiTokenFromResponse(createNonSuperuser); Response nonSuperuser = UtilIT.listAuthenticatedUsers(nonSuperuserApiToken); nonSuperuser.prettyPrint(); nonSuperuser.then().assertThat().statusCode(FORBIDDEN.getStatusCode()); - + Response createSuperuser = UtilIT.createRandomUser(); String superuserUsername = UtilIT.getUsernameFromResponse(createSuperuser); String superuserApiToken = UtilIT.getApiTokenFromResponse(createSuperuser); @@ -57,6 +62,202 @@ public void testListAuthenticatedUsers() throws Exception { } + + @Test + public void testFilterAuthenticatedUsersForbidden() throws Exception { + + // -------------------------------------------- + // Forbidden: Try *without* an API token + // -------------------------------------------- + Response anon = UtilIT.filterAuthenticatedUsers("", null, null, null); + anon.prettyPrint(); + anon.then().assertThat().statusCode(FORBIDDEN.getStatusCode()); + + // -------------------------------------------- + // Forbidden: Try with a regular user--*not a superuser* + // -------------------------------------------- + Response createUserResponse = UtilIT.createRandomUser(); + createUserResponse.then().assertThat().statusCode(OK.getStatusCode()); + + String nonSuperuserApiToken = UtilIT.getApiTokenFromResponse(createUserResponse); + String nonSuperUsername = UtilIT.getUsernameFromResponse(createUserResponse); + + Response filterResponseBadToken = UtilIT.filterAuthenticatedUsers(nonSuperuserApiToken, null, null, null); + filterResponseBadToken.then().assertThat().statusCode(FORBIDDEN.getStatusCode()); + + // delete user + Response deleteNonSuperuser = UtilIT.deleteUser(nonSuperUsername); + assertEquals(200, deleteNonSuperuser.getStatusCode()); + } + + /** + * Run multiple test against API endpoint to search authenticated users + * @throws Exception + */ + @Test + public void testFilterAuthenticatedUsers() throws Exception { + + Response createUserResponse; + + // -------------------------------------------- + // Make 11 random users + // -------------------------------------------- + String randUserNamePrefix = "r" + UtilIT.getRandomString(4) + "_"; + + List randomUsernames = new ArrayList(); + for (int i = 0; i < 11; i++){ + + createUserResponse = UtilIT.createRandomUser(randUserNamePrefix); + createUserResponse.then().assertThat().statusCode(OK.getStatusCode()); + String newUserName = UtilIT.getUsernameFromResponse(createUserResponse); + randomUsernames.add(newUserName); + + } + + // -------------------------------------------- + // Create superuser + // -------------------------------------------- + Response createSuperuser = UtilIT.createRandomUser(); + String superuserUsername = UtilIT.getUsernameFromResponse(createSuperuser); + String superuserApiToken = UtilIT.getApiTokenFromResponse(createSuperuser); + Response toggleSuperuser = UtilIT.makeSuperUser(superuserUsername); + toggleSuperuser.then().assertThat() + .statusCode(OK.getStatusCode()); + + + // -------------------------------------------- + // Search for the 11 new users and verify results + // -------------------------------------------- + Response filterReponse01 = UtilIT.filterAuthenticatedUsers(superuserApiToken, randUserNamePrefix, null, 100); + filterReponse01.then().assertThat().statusCode(OK.getStatusCode()); + filterReponse01.prettyPrint(); + + int numResults = 11; + filterReponse01.then().assertThat() + .body("data.userCount", equalTo(numResults)) + .body("data.selectedPage", equalTo(1)) + .body("data.pagination.pageCount", equalTo(1)) + .body("data.pagination.numResults", equalTo(numResults)); + + String userIdentifer; + for (int i=0; i < numResults; i++){ + userIdentifer = JsonPath.from(filterReponse01.getBody().asString()).getString("data.users[" + i + "].userIdentifier"); + assertEquals(randomUsernames.contains(userIdentifer), true); + } + + List userList1 = JsonPath.from(filterReponse01.body().asString()).getList("data.users"); + assertEquals(userList1.size(), numResults); + + // -------------------------------------------- + // Search for the 11 new users, but only return 5 per page + // -------------------------------------------- + int numUsersReturned = 5; + Response filterReponse02 = UtilIT.filterAuthenticatedUsers(superuserApiToken, randUserNamePrefix, 1, numUsersReturned); + filterReponse02.then().assertThat().statusCode(OK.getStatusCode()); + filterReponse02.prettyPrint(); + + filterReponse02.then().assertThat() + .body("data.userCount", equalTo(numResults)) + .body("data.selectedPage", equalTo(1)) + .body("data.pagination.docsPerPage", equalTo(numUsersReturned)) + .body("data.pagination.pageCount", equalTo(3)) + .body("data.pagination.numResults", equalTo(numResults)); + + String userIdentifer2; + for (int i=0; i < numUsersReturned; i++){ + userIdentifer2 = JsonPath.from(filterReponse02.getBody().asString()).getString("data.users[" + i + "].userIdentifier"); + assertEquals(randomUsernames.contains(userIdentifer2), true); + } + + List userList2 = JsonPath.from(filterReponse02.body().asString()).getList("data.users"); + assertEquals(userList2.size(), numUsersReturned); + + + // -------------------------------------------- + // Search for the 11 new users, return 5 per page, and start on NON-EXISTENT 4th page -- should revert to 1st page + // -------------------------------------------- + Response filterReponse02a = UtilIT.filterAuthenticatedUsers(superuserApiToken, randUserNamePrefix, 4, numUsersReturned); + filterReponse02a.then().assertThat().statusCode(OK.getStatusCode()); + filterReponse02a.prettyPrint(); + + filterReponse02a.then().assertThat() + .body("data.userCount", equalTo(numResults)) + .body("data.selectedPage", equalTo(1)) + .body("data.pagination.docsPerPage", equalTo(numUsersReturned)) + .body("data.pagination.pageCount", equalTo(3)) + .body("data.pagination.numResults", equalTo(numResults)); + + List userList2a = JsonPath.from(filterReponse02a.body().asString()).getList("data.users"); + assertEquals(userList2a.size(), numUsersReturned); + + // -------------------------------------------- + // Search for the 11 new users, return 5 per page, start on 3rd page + // -------------------------------------------- + Response filterReponse03 = UtilIT.filterAuthenticatedUsers(superuserApiToken, randUserNamePrefix, 3, 5); + filterReponse03.then().assertThat().statusCode(OK.getStatusCode()); + filterReponse03.prettyPrint(); + + filterReponse03.then().assertThat() + .body("data.userCount", equalTo(numResults)) + .body("data.selectedPage", equalTo(3)) + .body("data.pagination.docsPerPage", equalTo(5)) + .body("data.pagination.hasNextPageNumber", equalTo(false)) + .body("data.pagination.pageCount", equalTo(3)) + .body("data.pagination.numResults", equalTo(numResults)); + + List userList3 = JsonPath.from(filterReponse03.body().asString()).getList("data.users"); + assertEquals(userList3.size(), 1); + + // -------------------------------------------- + // Run search that returns no users + // -------------------------------------------- + Response filterReponse04 = UtilIT.filterAuthenticatedUsers(superuserApiToken, "zzz" + randUserNamePrefix, 1, 50); + filterReponse04.then().assertThat().statusCode(OK.getStatusCode()); + filterReponse04.prettyPrint(); + + filterReponse04.then().assertThat() + .body("data.userCount", equalTo(0)) + .body("data.selectedPage", equalTo(1)); + + List userList4 = JsonPath.from(filterReponse04.body().asString()).getList("data.users"); + assertEquals(userList4.size(), 0); + + + // -------------------------------------------- + // Run search that returns 1 user + // -------------------------------------------- + String singleUsername = randomUsernames.get(0); + Response filterReponse05 = UtilIT.filterAuthenticatedUsers(superuserApiToken, singleUsername, 1, 50); + filterReponse05.then().assertThat().statusCode(OK.getStatusCode()); + filterReponse05.prettyPrint(); + + filterReponse05.then().assertThat() + .body("data.userCount", equalTo(1)) + .body("data.selectedPage", equalTo(1)); + + List userList5 = JsonPath.from(filterReponse05.body().asString()).getList("data.users"); + assertEquals(userList5.size(), 1); + + // -------------------------------------------- + // Delete random users + // -------------------------------------------- + Response deleteUserResponse; + for (String aUsername : randomUsernames){ + + deleteUserResponse = UtilIT.deleteUser(aUsername); + assertEquals(200, deleteUserResponse.getStatusCode()); + + } + + // -------------------------------------------- + // Delete superuser + // -------------------------------------------- + deleteUserResponse = UtilIT.deleteUser(superuserUsername); + assertEquals(200, deleteUserResponse.getStatusCode()); + + + } + @Test public void testConvertShibUserToBuiltin() throws Exception { diff --git a/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java b/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java index 0b40986b3c8..59d1e0f6270 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java @@ -29,6 +29,9 @@ import org.apache.commons.io.IOUtils; import static com.jayway.restassured.RestAssured.given; import static com.jayway.restassured.path.xml.XmlPath.from; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map.Entry; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; @@ -57,8 +60,14 @@ static String getRestAssuredBaseUri() { return restAssuredBaseUri; } - public static Response createRandomUser() { - String randomString = getRandomUsername(); + /** + * Begin each username with a prefix + * + * @param usernamePrefix + * @return + */ + public static Response createRandomUser(String usernamePrefix) { + String randomString = getRandomUsername(usernamePrefix); logger.info("Creating random test user " + randomString); String userAsJson = getUserAsJsonString(randomString, randomString, randomString); String password = getPassword(userAsJson); @@ -69,6 +78,13 @@ public static Response createRandomUser() { return response; } + + + public static Response createRandomUser() { + + return createRandomUser("user"); + } + private static String getUserAsJsonString(String username, String firstName, String lastName) { JsonObjectBuilder builder = Json.createObjectBuilder(); builder.add(USERNAME_KEY, username); @@ -113,6 +129,21 @@ private static String getPassword(String jsonStr) { return password; } + private static String getRandomUsername(String usernamePrefix) { + + if (usernamePrefix == null){ + return getRandomUsername(); + } + return usernamePrefix + getRandomIdentifier().substring(0, 8); + } + + public static String getRandomString(int length) { + if (length < 0){ + length = 3; + } + return getRandomIdentifier().substring(0, length+1); + } + private static String getRandomUsername() { return "user" + getRandomIdentifier().substring(0, 8); } @@ -611,6 +642,47 @@ static Response getAuthenticatedUser(String userIdentifier, String apiToken) { return response; } + /** + * Used to the test the filter Authenticated Users API endpoint + * + * Note 1 : All params are optional for endpoint to work EXCEPT superUserApiToken + * Note 2 : sortKey exists in API call but not currently used + * + * @param apiToken + * @return + */ + static Response filterAuthenticatedUsers(String superUserApiToken, + String searchTerm, + Integer selectedPage, + Integer itemsPerPage + // String sortKey + ) { + + + List queryParams = new ArrayList(); + if (searchTerm != null){ + queryParams.add("searchTerm=" + searchTerm); + } + if (selectedPage != null){ + queryParams.add("selectedPage=" + selectedPage.toString()); + } + if (itemsPerPage != null){ + queryParams.add("itemsPerPage=" + itemsPerPage.toString()); + } + + String queryString = ""; + if (queryParams.size() > 0){ + queryString = "?" + String.join("&", queryParams); + } + + Response response = given() + .header(API_TOKEN_HTTP_HEADER, superUserApiToken) + .get("/api/admin/list-users" + queryString); + + return response; + } + + static Response getAuthProviders(String apiToken) { Response response = given() .header(API_TOKEN_HTTP_HEADER, apiToken) diff --git a/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationProviderTest.java b/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationProviderTest.java new file mode 100644 index 00000000000..7792288718f --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/authorization/AuthenticationProviderTest.java @@ -0,0 +1,96 @@ +/* + * To change this license header, choose License Headers in Project Properties. + * To change this template file, choose Tools | Templates + * and open the template in the editor. + */ +package edu.harvard.iq.dataverse.authorization; + +import edu.harvard.iq.dataverse.util.BundleUtil; +import java.util.AbstractMap.SimpleEntry; +import java.util.Collections; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import static org.junit.Assert.*; + +/** + * + * @author rmp553 + */ +public class AuthenticationProviderTest { + + public AuthenticationProviderTest() { + } + + @BeforeClass + public static void setUpClass() { + } + + @AfterClass + public static void tearDownClass() { + } + + @Before + public void setUp() { + } + + @After + public void tearDown() { + } + + /** + * Create a map used to test known AuthenticationProvider ids + * + * @return + */ + private Map getBundleTestMap(){ + return Collections.unmodifiableMap(Stream.of( + new SimpleEntry<>("builtin", "authenticationProvider.name.builtin"), + new SimpleEntry<>("github", "authenticationProvider.name.github"), + new SimpleEntry<>("google", "authenticationProvider.name.google"), + new SimpleEntry<>("orcid", "authenticationProvider.name.orcid"), + new SimpleEntry<>("orcid-sandbox", "authenticationProvider.name.orcid-sandbox"), + new SimpleEntry<>("shib", "authenticationProvider.name.shib")) + .collect(Collectors.toMap((e) -> e.getKey(), (e) -> e.getValue()))); + } + + /** + * Test of getFriendlyName method, of class AuthenticationProvider. + */ + @Test + public void testGetFriendlyName() { + System.out.println("getFriendlyName"); + + Map bundleTestMap = this.getBundleTestMap(); + + // ------------------------------------------ + // Test a null + // ------------------------------------------ + String expResult = BundleUtil.getStringFromBundle("authenticationProvider.name.null"); + assertEquals(expResult, AuthenticationProvider.getFriendlyName(null)); + + // ------------------------------------------ + // Test an id w/o a bundle entry--should default to id + // ------------------------------------------ + String idNotInBundle = "id-not-in-bundle-so-use-id"; + String expResult2 = AuthenticationProvider.getFriendlyName(idNotInBundle); + assertEquals(expResult2, idNotInBundle); + + // ------------------------------------------ + // Iterate through the map and test each item + // ------------------------------------------ + bundleTestMap.forEach((authProviderId, bundleName)->{ + String expectedResult = BundleUtil.getStringFromBundle(bundleName); + assertEquals(expectedResult, AuthenticationProvider.getFriendlyName(authProviderId)); + }); + + } + + + +}