From 33eb056a356d18396d8dff0b420fb23e213d41a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20H=C3=B6ld?= Date: Tue, 21 May 2019 09:17:59 +0200 Subject: [PATCH 01/21] ui: Fix display of 'No event' in history 'No event' is displayed only if there are no elements returned from the API. This is achieved by only displaying the element if the loading process is finished. --- CHANGELOG.md | 4 +++- frontend/src/pages/Common/HistoryList.js | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28594f2db..89dc0e7cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - +### Fixed + +- Empty history displayed after API call is finished [#294](https://github.com/openkfw/TruBudget/issues/294) ## [1.0.1] - 2019-05-21 diff --git a/frontend/src/pages/Common/HistoryList.js b/frontend/src/pages/Common/HistoryList.js index bf7f9ff63..8476fd13b 100644 --- a/frontend/src/pages/Common/HistoryList.js +++ b/frontend/src/pages/Common/HistoryList.js @@ -41,7 +41,7 @@ export default function HistoryList({ events, nEventsTotal, hasMore, isLoading, subheader={{strings.common.history}} style={styles.list} > - {nEventsTotal === 0 ? ( + {!isLoading && nEventsTotal === 0 ? ( From 82c3f4b33ae0272afd58685a1c65fd52c3acf48b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20H=C3=B6ld?= Date: Tue, 21 May 2019 11:32:55 +0200 Subject: [PATCH 02/21] excel-export: Fix npm audit --- excel-export/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/excel-export/package-lock.json b/excel-export/package-lock.json index 9248a06f5..8b7c7ce89 100644 --- a/excel-export/package-lock.json +++ b/excel-export/package-lock.json @@ -335,9 +335,9 @@ "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" }, "fstream": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.11.tgz", - "integrity": "sha1-XB+x8RdHcRTwYyoOtLcbPLD9MXE=", + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", "requires": { "graceful-fs": "^4.1.2", "inherits": "~2.0.0", From 8677771b2f7da92a973f287b58cc8370b449be99 Mon Sep 17 00:00:00 2001 From: Philip Pai Date: Wed, 22 May 2019 17:20:12 +0200 Subject: [PATCH 03/21] correct links in readme [skip ci] --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9960681e1..438665d1a 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ These instructions will help you deploy your own TruBudget platform having two n ## Starting the first TruBudget node The recommended option to get started with TruBudget is to use the latest stable docker images via docker-compose. -For more detailed information about the installation and the environment variables or alternative ways to setup TruBudget check out the [Installation Guide](./doc/wiki/Installation-Guide/Installation-Guide.md). +For more detailed information about the installation and the environment variables or alternative ways to setup TruBudget check out the [Installation Guide](./doc/tutorials/installation/bare-metal-installation.md). The required environment variables are set in the `.env` file. If you want to use the standard setup, simply copy the `.env_example` file, otherwise explore the posible configuration options in it: @@ -58,7 +58,7 @@ sh scripts/master/start-master-node.sh > In case you want to start with a set of example data, you can also start TruBudget with the following script `sh scripts/master/start-and-provision-master-node.sh`. The process of provisioning may take several minutes (depending on your CPU) and can slow down your computer during the execution of the script. After provisioning you have acces to a set of users (e.g. `mstein` which share the password `test`) -This command will bootstrap a prod and test instance of TruBudget (blockchain, api, frontend) for you. Use `docker ps` to check on the running containers. You should see the following output (you can find more details about the runtime architecture in the [Infrastructure-Guide](./doc/wiki/Infrastructure-Guide/Infrastructure-Guide.md)): +This command will bootstrap a prod and test instance of TruBudget (blockchain, api, frontend) for you. Use `docker ps` to check on the running containers. You should see the following output: ```bash ➜ docker ps @@ -176,7 +176,7 @@ Obviously this is just a short introduction on how to start and use the platform # Build and Develop from Local Sources -Checkout the [Contributor Guide](./doc/wiki/Contributor-Guide/Contributor-Guide.md) to learn how to set up your environment to start developing and debugging the TruBudget application. +Checkout the [Contributor Guide](./doc/tutorials/contribute/Contributor-Guide.md) to learn how to set up your environment to start developing and debugging the TruBudget application. From 93f20dfb6d2c42433126b89f9e5612219a33ffcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20H=C3=B6ld?= Date: Thu, 23 May 2019 11:34:57 +0200 Subject: [PATCH 04/21] ui: Set isHistoryLoading when opening history drawer --- frontend/src/pages/SubProjects/reducer.js | 3 ++- frontend/src/pages/Workflows/reducer.js | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/SubProjects/reducer.js b/frontend/src/pages/SubProjects/reducer.js index 9de2c14d1..fd38a9132 100644 --- a/frontend/src/pages/SubProjects/reducer.js +++ b/frontend/src/pages/SubProjects/reducer.js @@ -146,7 +146,8 @@ export default function detailviewReducer(state = defaultState, action) { isHistoryLoading: false }); case OPEN_HISTORY: - return state.set("showHistory", true); + return state.set("showHistory", true).set("isHistoryLoading", true); + case HIDE_HISTORY: return state.merge({ historyItems: fromJS([]), diff --git a/frontend/src/pages/Workflows/reducer.js b/frontend/src/pages/Workflows/reducer.js index bde6e08ba..87a5c4a69 100644 --- a/frontend/src/pages/Workflows/reducer.js +++ b/frontend/src/pages/Workflows/reducer.js @@ -326,7 +326,7 @@ export default function detailviewReducer(state = defaultState, action) { case LOGOUT: return defaultState; case OPEN_HISTORY: - return state.set("showHistory", true); + return state.set("showHistory", true).set("isHistoryLoading", true); default: return state; } From 783f81a783c67eb811aa71e286f66e5d84aebb40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20H=C3=B6ld?= Date: Mon, 20 May 2019 15:03:16 +0200 Subject: [PATCH 05/21] Change and improve implementation for notifications Pagnination The implementation for fetching notifications is changed. Instead of keeping offset and limit in the state, the page is fetched by page number instead (similar to the history). The issue of overfetching on the last page was solved by calculating the number of remaining items to be fetched for the last page. Closes #288 Prevent assignee selection from overflowing When the name of the assignee is too long it gets shortened by ellipses in the workflow item table. Closes #299 Display parent project and subproject name for notifications The displaynames of the project and subproject are now displayed on the notification page for each entry. If the user does not have permissions to see the project/subproject it gets replaced by the word "Redacted". Closes #298 Display correct names in notifications If the user does not have the permission to see a project/subproject/workflowitem, the displayname is not displayed in the notification and is instead left blank. Closes #292 Refactor Helpers - Functions that are not used were removed - Other functions were re-written for code clarity Move 'Read All' button To increase usability, the 'Read All' button was moved to the left side of the page. It is now above the icon that indicates whether the notification has been read which makes its purpose clearer. The color has also been changed to black instead of blue. Closes #301 Background color for unread notifications To increase usability, the background color for unread notifications has been changed to a light gray color. Closes #300 Remove view button if disabled - The 'view' button has been changed to an icon button instead of floating action button to make the look a little leaner - The button has been surrounded by a tooltip which tells the user what the button does - If the user does not have the permissions to see the project/subproject/workflowitem, the button is not displayed instead of disabled Closes #302 --- CHANGELOG.md | 11 +- .../cypress/integration/notification_spec.js | 1 + .../integration/workflowitem_history_spec.js | 2 +- frontend/src/helper.js | 135 ++---------------- frontend/src/index.js | 6 +- .../pages/Notifications/FlyInNotifications.js | 21 +-- .../pages/Notifications/NotificationList.js | 102 ++++--------- .../Notifications/NotificationListItems.js | 39 +++-- .../pages/Notifications/NotificationPage.js | 4 +- .../NotificationPageContainer.js | 21 ++- frontend/src/pages/Notifications/actions.js | 38 ++--- frontend/src/pages/Notifications/helper.js | 89 ++++++++---- frontend/src/pages/Notifications/reducer.js | 19 +-- .../Workflows/WorkflowAssigneeContainer.js | 4 +- frontend/src/sagas.js | 66 +++++---- 15 files changed, 227 insertions(+), 331 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89dc0e7cb..bf65be249 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,13 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). ## [Unreleased] - +### Added +- Different background color for unread notifications [#300](https://github.com/openkfw/TruBudget/issues/300) - +### Changed +- Notification displays name of parent project and subproject [#298](https://github.com/openkfw/TruBudget/issues/298) +- Move 'Read All' button to the left side [#301](https://github.com/openkfw/TruBudget/issues/301) +- Don't display view button if user is not allowed to see project/subproject [#302](https://github.com/openkfw/TruBudget/issues/302) @@ -17,6 +21,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Fixed - Empty history displayed after API call is finished [#294](https://github.com/openkfw/TruBudget/issues/294) +- Last page of notifications displays correct number of items [#288](https://github.com/openkfw/TruBudget/issues/288) +- Prevent assignee selection from overflowing [#299](https://github.com/openkfw/TruBudget/issues/299) +- Display correct name in notifications [#292](https://github.com/openkfw/TruBudget/issues/292) ## [1.0.1] - 2019-05-21 diff --git a/e2e-test/cypress/integration/notification_spec.js b/e2e-test/cypress/integration/notification_spec.js index 540d1d1b5..6b94bb049 100644 --- a/e2e-test/cypress/integration/notification_spec.js +++ b/e2e-test/cypress/integration/notification_spec.js @@ -13,6 +13,7 @@ describe("open notifications", function() { .then(() => cy.closeProject(projectId, "project.close", assignee)) .then(() => cy.login("jxavier")) .then(() => cy.visit("/notifications")) + .then(() => cy.get("[data-test=notification-unread-0]").should("be.visible")) ); }); diff --git a/e2e-test/cypress/integration/workflowitem_history_spec.js b/e2e-test/cypress/integration/workflowitem_history_spec.js index be8bca7c6..60211ad01 100644 --- a/e2e-test/cypress/integration/workflowitem_history_spec.js +++ b/e2e-test/cypress/integration/workflowitem_history_spec.js @@ -45,7 +45,7 @@ describe("Workflowitem's history", function() { it("The history is sorted from new to old", function() { // Change assignee to create new history event - cy.get(".workflowitem-assignee").click(); + cy.get("[data-test=workflowitem-assignee]").click(); cy.get("[role=listbox]") .find("[value=jdoe]") .click() diff --git a/frontend/src/helper.js b/frontend/src/helper.js index 7bac4f67a..3217a7fe0 100644 --- a/frontend/src/helper.js +++ b/frontend/src/helper.js @@ -1,29 +1,24 @@ -import React from "react"; -import { Iterable } from "immutable"; -import dayjs from "dayjs"; - -import OpenIcon from "@material-ui/icons/Remove"; import DoneIcon from "@material-ui/icons/Check"; - -import indigo from "@material-ui/core/colors/indigo"; - +import OpenIcon from "@material-ui/icons/Remove"; +import accounting from "accounting"; +import dayjs from "dayjs"; +import { Iterable } from "immutable"; +import _cloneDeep from "lodash/cloneDeep"; import _isEmpty from "lodash/isEmpty"; import _isEqual from "lodash/isEqual"; -import _cloneDeep from "lodash/cloneDeep"; -import _isUndefined from "lodash/isUndefined"; import _isString from "lodash/isString"; +import _isUndefined from "lodash/isUndefined"; +import React from "react"; -import accounting from "accounting"; -import strings from "./localizeStrings"; import currencies from "./currency"; +import strings from "./localizeStrings"; + const numberFormat = { decimal: ".", thousand: ",", precision: 2 }; -const statusColors = [indigo[100], indigo[300]]; - export const toJS = WrappedComponent => wrappedComponentProps => { const KEY = 0; const VALUE = 1; @@ -71,12 +66,6 @@ export const fromAmountString = (amount, currency) => { return accounting.unformat(amount, getCurrencyFormat(currency).decimal); }; -export const formatAmountString = (amount, currency) => { - if (_isString(amount) && amount.trim().length <= 0) { - return ""; - } - return amount; -}; export const getCurrencies = () => { return Object.keys(currencies).map(currency => { return { @@ -97,11 +86,6 @@ export const toAmountString = (amount, currency) => { return accounting.formatMoney(amount, getCurrencyFormat(currency)); }; -export const tsToString = ts => { - let dateString = dayjs.unix(ts).format("MMM D, YYYY"); - return dateString; -}; - export const unixTsToString = ts => { let dateString = dayjs.unix(ts).format("MMM D, YYYY"); return dateString; @@ -136,112 +120,11 @@ export const statusIconMapping = { open: }; -export const roleMapper = { - approver: strings.common.approver, - bank: strings.common.bank, - assignee: strings.common.assignee -}; - -export const createDoughnutData = (labels, data, colors = statusColors) => ({ - labels, - datasets: [ - { - data: data, - backgroundColor: colors, - hoverBackgroundColor: colors - } - ] -}); - -export const calculateUnspentAmount = items => { - const amount = items.reduce((acc, item) => { - return acc + parseFloat(item.data.amount, 10); - }, 0); - return amount; -}; - -export const getCompletionRatio = subprojects => { - const completedSubprojects = getCompletedSubprojects(subprojects); - const percentageCompleted = completedSubprojects.length / subprojects.length * 100; - return percentageCompleted > 0 ? percentageCompleted : 0; -}; - -const getCompletedSubprojects = subprojects => { - const completedSubprojects = subprojects.filter(subproject => { - return subproject.data.status === "closed"; - }); - return completedSubprojects; -}; - -export const getCompletionString = subprojects => { - const completedSubprojects = getCompletedSubprojects(subprojects); - return strings.formatString( - strings.subproject.subproject_completion_string, - completedSubprojects.length, - subprojects.length - ); -}; - export const formatString = (text, ...args) => { return strings.formatString(text, ...args); }; -export const formatUpdateString = (identifier, createdBy, data) => { - let string = strings.formatString(strings.history.changed_by, identifier, createdBy); - const changes = Object.keys(data) - .map(key => formatString(strings.history.to, key, data[key])) - .join(", "); - return string.concat(changes); -}; - -export const calculateWorkflowBudget = workflows => { - return workflows.reduce( - (acc, workflow) => { - const { amount, amountType, status } = workflow.data; - const parsedAmount = parseFloat(amount, 10); - const next = { - assigned: amountType === "allocated" ? acc.assigned + parsedAmount : acc.assigned, - disbursed: amountType === "disbursed" ? acc.disbursed + parsedAmount : acc.disbursed, - currentDisbursement: - amountType === "disbursed" && status === "closed" - ? acc.currentDisbursement + parsedAmount - : acc.currentDisbursement - }; - return next; - }, - { - assigned: 0, - disbursed: 0, - currentDisbursement: 0 - } - ); -}; - -export const getNotAssignedBudget = (amount, assignedBudget, disbursedBudget) => { - const notAssigned = amount - assignedBudget - disbursedBudget; - return notAssigned >= 0 ? notAssigned : 0; -}; - -export const getProgressInformation = items => { - let startValue = { - open: 0, - closed: 0 - }; - const projectStatus = items.reduce((acc, item) => { - const status = item.data.status; - return { - open: status === "open" ? acc.open + 1 : acc.open, - closed: status === "closed" ? acc.closed + 1 : acc.closed - }; - }, startValue); - return projectStatus; -}; export const preselectCurrency = (parentCurrency, setCurrency) => { const preSelectedCurrency = _isUndefined(parentCurrency) ? "EUR" : parentCurrency; setCurrency(preSelectedCurrency); }; - -export const createTaskData = (items, type) => { - const projectStatus = getProgressInformation(items); - return createDoughnutData([strings.common.open, strings.common.closed], [projectStatus.open, projectStatus.closed]); -}; diff --git a/frontend/src/index.js b/frontend/src/index.js index aabb3a0f7..180000e44 100644 --- a/frontend/src/index.js +++ b/frontend/src/index.js @@ -10,6 +10,7 @@ import relativeTime from "dayjs/plugin/relativeTime"; import red from "@material-ui/core/colors/deepOrange"; import blue from "@material-ui/core/colors/indigo"; +import grey from "@material-ui/core/colors/grey"; import Main from "./pages/Main/Main"; import LoginPageContainer from "./pages/Login/LoginPageContainer"; @@ -31,7 +32,10 @@ const store = configureStore(initialState, history); const muiTheme = createMuiTheme({ palette: { primary: blue, - secondary: red + secondary: red, + grey: { + main: grey[100] + } }, typography: { useNextVariants: true diff --git a/frontend/src/pages/Notifications/FlyInNotifications.js b/frontend/src/pages/Notifications/FlyInNotifications.js index a1c430bd8..6501ffaa3 100644 --- a/frontend/src/pages/Notifications/FlyInNotifications.js +++ b/frontend/src/pages/Notifications/FlyInNotifications.js @@ -9,7 +9,7 @@ import IconButton from "@material-ui/core/IconButton"; import LaunchIcon from "@material-ui/icons/ZoomIn"; import Typography from "@material-ui/core/Typography"; -import { intentMapping, getDisplayName, parseURI, isAllowedToSee } from "./helper"; +import { intentMapping, parseURI, isAllowedToSee, getParentData } from "./helper"; const styles = { notification: { @@ -32,6 +32,7 @@ export default class FlyInNotification extends Component { const subprojectId = metadata.subproject ? metadata.subproject.id : undefined; const { publisher } = businessEvent; const message = intentMapping(notification); + const { projectDisplayName, subprojectDisplayName } = getParentData(notification); return ( {publisher ? publisher[0].toString().toUpperCase() : "?"}} action={ - history.push(parseURI({ projectId, subprojectId }))} - > - - + isAllowedToSee(notification) ? ( + history.push(parseURI({ projectId, subprojectId }))} + > + + + ) : null } - title={getDisplayName(notification)} + title={projectDisplayName + " " + subprojectDisplayName} /> {message} diff --git a/frontend/src/pages/Notifications/NotificationList.js b/frontend/src/pages/Notifications/NotificationList.js index 3434a784f..b31f7509f 100644 --- a/frontend/src/pages/Notifications/NotificationList.js +++ b/frontend/src/pages/Notifications/NotificationList.js @@ -22,54 +22,21 @@ const styles = { } }; -const markPageAsRead = (markMultipleNotificationsAsRead, notifications, notificationOffset, notificationsPerPage) => { +const markPageAsRead = (markMultipleNotificationsAsRead, notifications, notificationPage) => { const notificationIds = notifications.map(notification => notification.id); - markMultipleNotificationsAsRead(notificationIds, notificationOffset, notificationsPerPage); + markMultipleNotificationsAsRead(notificationIds, notificationPage); }; const onChangeRowsPerPage = ( - event, + newNotificationsPerPage, setNotifcationsPerPage, - notificationOffset, fetchNotifications, - setNotificationOffset -) => { - let offset = notificationOffset; - if (offset < event.target.value) { - offset = 0; - } else if (offset % event.target.value > 0) { - offset = offset - offset % event.target.value; - } - setNotifcationsPerPage(event.target.value); - fetchNotifications(offset, event.target.value); - setNotificationOffset(offset); -}; - -const onChangePage = ( - nextPage, currentPage, - notificationOffset, - notificationsPerPage, - fetchNotifications, - setNotificationOffset + notificationsPerPage ) => { - if (nextPage > currentPage) { - //Moving forward - let offset = 0; - if (nextPage > 0) { - offset = notificationOffset + notificationsPerPage; - } - fetchNotifications(offset, notificationsPerPage); - setNotificationOffset(offset); - } else { - //moving backward - let offset = 0; - if (nextPage > 0) { - offset = notificationOffset - notificationsPerPage; - } - fetchNotifications(offset, notificationsPerPage); - setNotificationOffset(offset); - } + setNotifcationsPerPage(newNotificationsPerPage); + //Fetch first page again + fetchNotifications(0); }; const NotificationsList = props => { @@ -82,37 +49,33 @@ const NotificationsList = props => { fetchNotifications, notificationCount, notificationOffset, - setNotificationOffset, history, - markNotificationAsRead + markNotificationAsRead, + currentPage } = props; const allNotificationsRead = notifications.some(notification => notification.isRead === false); const rowsPerPageOptions = [5, 10, 20, 50]; - const currentPage = Math.floor(notificationOffset / notificationsPerPage); return ( - - markPageAsRead(markMultipleNotificationsAsRead, notifications, notificationOffset, notificationsPerPage) - } - color="primary" - className={classes.button + " mark-all-notifications-as-read"} - data-test="read-multiple-notifications" - disabled={!allNotificationsRead} - > - {strings.notification.read_all} - - } - /> + +
+ +
+ markNotificationAsRead(notificationId, currentPage)} notificationsPerPage={notificationsPerPage} notificationOffset={notificationOffset} /> @@ -124,25 +87,16 @@ const NotificationsList = props => { rowsPerPage={notificationsPerPage} onChangeRowsPerPage={event => onChangeRowsPerPage( - event, + event.target.value, setNotifcationsPerPage, - notificationOffset, fetchNotifications, - setNotificationOffset + currentPage, + notificationsPerPage ) } count={notificationCount} page={currentPage} - onChangePage={(_, nextPage) => - onChangePage( - nextPage, - currentPage, - notificationOffset, - notificationsPerPage, - fetchNotifications, - setNotificationOffset - ) - } + onChangePage={(_, nextPage) => fetchNotifications(nextPage)} />
diff --git a/frontend/src/pages/Notifications/NotificationListItems.js b/frontend/src/pages/Notifications/NotificationListItems.js index 02254c60e..ac8d29a77 100644 --- a/frontend/src/pages/Notifications/NotificationListItems.js +++ b/frontend/src/pages/Notifications/NotificationListItems.js @@ -1,6 +1,7 @@ import { withStyles } from "@material-ui/core/styles"; +import IconButton from "@material-ui/core/IconButton"; +import Tooltip from "@material-ui/core/Tooltip"; import Divider from "@material-ui/core/Divider"; -import Fab from "@material-ui/core/Fab"; import ListItem from "@material-ui/core/ListItem"; import ListItemIcon from "@material-ui/core/ListItemIcon"; import ListItemText from "@material-ui/core/ListItemText"; @@ -9,9 +10,10 @@ import Read from "@material-ui/icons/MailOutline"; import LaunchIcon from "@material-ui/icons/ZoomIn"; import dayjs from "dayjs"; import React from "react"; +import classNames from "classnames"; -import { intentMapping, parseURI, isAllowedToSee } from "./helper"; - +import { intentMapping, parseURI, getParentData, isAllowedToSee } from "./helper"; +import strings from "../../localizeStrings"; const styles = theme => ({ row: { display: "flex", @@ -38,6 +40,9 @@ const styles = theme => ({ unread: { flex: 1, opacity: 1 + }, + unreadMessage: { + backgroundColor: theme.palette.grey.main } }); @@ -59,12 +64,13 @@ const NotificationListItems = ({ subprojectId: metadata.subproject ? metadata.subproject.id : undefined }); const testLabel = `notification-${isRead ? "read" : "unread"}`; + const { projectDisplayName, subprojectDisplayName } = getParentData(notification); return (
{isRead ? : }
- - +
- history.push(redirectUri)} - > - - + {isAllowedToSee(notification) ? ( + +
+ history.push(redirectUri)}> + + +
+
+ ) : null}
diff --git a/frontend/src/pages/Notifications/NotificationPage.js b/frontend/src/pages/Notifications/NotificationPage.js index 508da4b66..280a818d6 100644 --- a/frontend/src/pages/Notifications/NotificationPage.js +++ b/frontend/src/pages/Notifications/NotificationPage.js @@ -12,7 +12,7 @@ const NotificationPage = ({ fetchNotifications, notificationCount, notificationOffset, - setNotificationOffset + currentPage }) => { return (
@@ -25,8 +25,8 @@ const NotificationPage = ({ notificationsPerPage={notificationsPerPage} fetchNotifications={fetchNotifications} notificationCount={notificationCount} - setNotificationOffset={setNotificationOffset} notificationOffset={notificationOffset} + currentPage={currentPage} />
); diff --git a/frontend/src/pages/Notifications/NotificationPageContainer.js b/frontend/src/pages/Notifications/NotificationPageContainer.js index 819a13876..188c8e6d7 100644 --- a/frontend/src/pages/Notifications/NotificationPageContainer.js +++ b/frontend/src/pages/Notifications/NotificationPageContainer.js @@ -5,7 +5,6 @@ import { markNotificationAsRead, fetchNotifications, setNotifcationsPerPage, - setNotificationOffset, markMultipleNotificationsAsRead, enableLiveUpdates, fetchNotificationCounts, @@ -18,7 +17,7 @@ import { toJS } from "../../helper"; class NotificationPageContainer extends Component { componentWillMount() { - this.props.fetchNotifications(this.props.notificationOffset, this.props.notificationsPerPage); + this.props.fetchNotifications(this.props.currentPage); this.props.disableLiveUpdates(); } @@ -38,13 +37,11 @@ class NotificationPageContainer extends Component { const mapDispatchToProps = (dispatch, props) => { return { - fetchNotifications: (offset, limit) => dispatch(fetchNotifications(true, offset, limit)), - markNotificationAsRead: (notificationId, offset, limit) => - dispatch(markNotificationAsRead(notificationId, offset, limit)), - markMultipleNotificationsAsRead: (notificationIds, offset, limit) => - dispatch(markMultipleNotificationsAsRead(notificationIds, offset, limit)), - setNotifcationsPerPage: limit => dispatch(setNotifcationsPerPage(limit)), - setNotificationOffset: offset => dispatch(setNotificationOffset(offset)), + fetchNotifications: page => dispatch(fetchNotifications(true, page)), + markNotificationAsRead: (notificationId, page) => dispatch(markNotificationAsRead(notificationId, page)), + markMultipleNotificationsAsRead: (notificationIds, page) => + dispatch(markMultipleNotificationsAsRead(notificationIds, page)), + setNotifcationsPerPage: notificationPageSize => dispatch(setNotifcationsPerPage(notificationPageSize)), enableLiveUpdates: () => dispatch(enableLiveUpdates()), disableLiveUpdates: () => dispatch(disableLiveUpdates()), fetchNotificationCounts: () => dispatch(fetchNotificationCounts()) @@ -54,10 +51,10 @@ const mapDispatchToProps = (dispatch, props) => { const mapStateToProps = state => { return { notifications: state.getIn(["notifications", "notifications"]), - notificationsPerPage: state.getIn(["notifications", "notificationsPerPage"]), + notificationsPerPage: state.getIn(["notifications", "notificationPageSize"]), unreadNotificationCount: state.getIn(["notifications", "unreadNotificationCount"]), - notificationCount: state.getIn(["notifications", "notificationCount"]), - notificationOffset: state.getIn(["notifications", "notificationOffset"]) + notificationCount: state.getIn(["notifications", "totalNotificationCount"]), + currentPage: state.getIn(["notifications", "currentNotificationPage"]) }; }; diff --git a/frontend/src/pages/Notifications/actions.js b/frontend/src/pages/Notifications/actions.js index 9bd375513..74e7e625f 100644 --- a/frontend/src/pages/Notifications/actions.js +++ b/frontend/src/pages/Notifications/actions.js @@ -20,11 +20,11 @@ export const FETCH_ALL_NOTIFICATIONS_SUCCESS = "FETCH_ALL_NOTIFICATIONS_SUCCESS" export const LIVE_UPDATE_NOTIFICATIONS = "LIVE_UPDATE_NOTIFICATIONS"; export const LIVE_UPDATE_NOTIFICATIONS_SUCCESS = "LIVE_UPDATE_NOTIFICATIONS_SUCCESS"; -export const MARK_MULTIPLE_NOTIFICATION_AS_READ = "MARK_MULTIPLE_NOTIFICATION_AS_READ"; -export const MARK_MULTIPLE_NOTIFICATION_AS_READ_SUCCESS = "MARK_MULTIPLE_NOTIFICATION_AS_READ_SUCCESS"; +export const MARK_MULTIPLE_NOTIFICATIONS_AS_READ = "MARK_MULTIPLE_NOTIFICATIONS_AS_READ"; +export const MARK_MULTIPLE_NOTIFICATIONS_AS_READ_SUCCESS = "MARK_MULTIPLE_NOTIFICATIONS_AS_READ_SUCCESS"; -export const FETCH_NOTIFICATION_COUNTS = "FETCH_NOTIFICATION_COUNTS"; -export const FETCH_NOTIFICATION_COUNTS_SUCCESS = "FETCH_NOTIFICATION_COUNTS_SUCCESS"; +export const FETCH_NOTIFICATION_COUNT = "FETCH_NOTIFICATION_COUNT"; +export const FETCH_NOTIFICATION_COUNT_SUCCESS = "FETCH_NOTIFICATION_COUNT_SUCCESS"; export const SET_NOTIFICATIONS_PER_PAGE = "SET_NOTIFICATIONS_PER_PAGE"; export const SET_NOTIFICATION_OFFSET = "SET_NOTIFICATION_OFFSET"; @@ -60,28 +60,26 @@ export function updateNotification(showLoading = false, offset) { offset }; } -export function fetchNotifications(showLoading = false, offset, limit) { +export function fetchNotifications(showLoading = false, notificationPage) { return { type: FETCH_ALL_NOTIFICATIONS, showLoading, - offset, - limit + notificationPage }; } export function fetchNotificationCounts(showLoading = false) { return { - type: FETCH_NOTIFICATION_COUNTS, + type: FETCH_NOTIFICATION_COUNT, showLoading }; } -export function markNotificationAsRead(notificationId, offset, limit) { +export function markNotificationAsRead(notificationId, notificationPage) { return { type: MARK_NOTIFICATION_AS_READ, notificationId, - offset, - limit + notificationPage }; } @@ -105,26 +103,18 @@ export function fetchHistoryItems(project, offset, limit) { }; } -export function markMultipleNotificationsAsRead(notificationIds, offset, limit) { +export function markMultipleNotificationsAsRead(notificationIds, notificationPage) { return { - type: MARK_MULTIPLE_NOTIFICATION_AS_READ, + type: MARK_MULTIPLE_NOTIFICATIONS_AS_READ, notificationIds, - offset, - limit + notificationPage }; } -export function setNotifcationsPerPage(limit) { +export function setNotifcationsPerPage(notificationPageSize) { return { type: SET_NOTIFICATIONS_PER_PAGE, - limit - }; -} - -export function setNotificationOffset(offset) { - return { - type: SET_NOTIFICATION_OFFSET, - offset + notificationPageSize: notificationPageSize }; } diff --git a/frontend/src/pages/Notifications/helper.js b/frontend/src/pages/Notifications/helper.js index 2a40de965..6318758c6 100644 --- a/frontend/src/pages/Notifications/helper.js +++ b/frontend/src/pages/Notifications/helper.js @@ -1,31 +1,76 @@ import strings from "../../localizeStrings"; import { formatString } from "../../helper"; -// get hierarchic deepest displayName -export function getDisplayName(notification) { +const ACCESSMAP = { + PROJECT: "project", + SUBPROJECT: "subproject", + WORKFLOWITEM: "workflowitem" +}; + +export function getParentData(notification) { + const redacted = "Redacted"; const metadata = notification.metadata; + let projectDisplayName = ""; + let subprojectDisplayName = ""; + if (metadata !== undefined) { - if (metadata.workflowitem !== undefined && metadata.workflowitem.hasViewPermissions === true) { - return metadata.workflowitem.displayName; - } - if (metadata.subproject !== undefined && metadata.subproject.hasViewPermissions === true) { - return metadata.subproject.displayName; - } - if (metadata.project !== undefined && metadata.project.hasViewPermissions === true) { - return metadata.project.displayName; + if (metadata.project) { + const { + displayName: projectName = "", + hasViewPermissions: hasProjectViewPermissions = false + } = getDataFromNotification(metadata, ACCESSMAP.PROJECT); + projectDisplayName = hasProjectViewPermissions ? projectName : redacted; + if (metadata.subproject) { + const { + displayName: subprojectName = "", + hasViewPermissions: hasSubprojectViewPermissions = false + } = getDataFromNotification(metadata, ACCESSMAP.SUBPROJECT); + subprojectDisplayName = hasSubprojectViewPermissions ? subprojectName : redacted; + } } } - return ""; + + return { + projectDisplayName, + subprojectDisplayName + }; } +export const isAllowedToSee = notification => { + const metadata = notification.metadata; + if (metadata !== undefined) { + const { hasViewPermissions = false } = getDataFromNotification(metadata); + return hasViewPermissions; + } + return false; +}; + export const intentMapping = notification => { const businessEvent = notification.businessEvent; + if (!businessEvent) { + console.warn("Notification has no business event"); + return ""; + } const translation = strings.notification[businessEvent.type]; - const displayName = getDisplayName(notification); - const text = formatString(translation, displayName); - return `${text} ${isAllowedToSee(notification) ? "" : strings.notification.no_permissions}`; + + const notificationMetaData = notification.metadata; + if (!notificationMetaData) { + console.warn("Notification has no metadata"); + return ""; + } + + const { displayName = "", hasViewPermissions = false } = getDataFromNotification(notificationMetaData); + + return hasViewPermissions + ? formatString(translation, displayName) + : formatString(translation, "") + " " + strings.notification.no_permissions; }; +const getDataFromNotification = (metadata, type) => + type + ? metadata[type] + : metadata[ACCESSMAP.WORKFLOWITEM] || metadata[ACCESSMAP.SUBPROJECT] || metadata[ACCESSMAP.PROJECT]; + export const parseURI = ({ projectId, subprojectId }) => { if (projectId && !subprojectId) { return `/projects/${projectId}`; @@ -35,19 +80,3 @@ export const parseURI = ({ projectId, subprojectId }) => { throw new Error("not implemented"); } }; - -export const isAllowedToSee = notification => { - const metadata = notification.metadata; - if (metadata !== undefined) { - if (metadata.workflowitem !== undefined) { - return metadata.workflowitem.hasViewPermissions; - } - if (metadata.subproject !== undefined) { - return metadata.subproject.hasViewPermissions; - } - if (metadata.project !== undefined) { - return metadata.project.hasViewPermissions; - } - } - return false; -}; diff --git a/frontend/src/pages/Notifications/reducer.js b/frontend/src/pages/Notifications/reducer.js index 32bb369a9..b544569f5 100644 --- a/frontend/src/pages/Notifications/reducer.js +++ b/frontend/src/pages/Notifications/reducer.js @@ -7,10 +7,9 @@ import { HIDE_HISTORY, FETCH_ALL_NOTIFICATIONS_SUCCESS, HIDE_SNACKBAR, - FETCH_NOTIFICATION_COUNTS_SUCCESS, + FETCH_NOTIFICATION_COUNT_SUCCESS, SET_NOTIFICATIONS_PER_PAGE, LIVE_UPDATE_NOTIFICATIONS_SUCCESS, - SET_NOTIFICATION_OFFSET, TIME_OUT_FLY_IN, ENABLE_LIVE_UPDATES, DISABLE_LIVE_UPDATES @@ -26,17 +25,21 @@ const defaultState = fromJS({ snackbarError: false, historyItems: [], unreadNotificationCount: 0, - notificationCount: 0, notificationsPerPage: 20, notificationOffset: 0, - isLiveUpdatesEnabled: true + isLiveUpdatesEnabled: true, + totalNotificationCount: 0, + currentNotificationPage: 1, + notificationPageSize: 20 }); export default function navbarReducer(state = defaultState, action) { switch (action.type) { case FETCH_ALL_NOTIFICATIONS_SUCCESS: return state.merge({ - notifications: fromJS(action.notifications) + notifications: fromJS(action.notifications), + currentNotificationPage: action.currentNotificationPage, + totalNotificationCount: action.totalNotificationCount }); case ENABLE_LIVE_UPDATES: { @@ -62,7 +65,7 @@ export default function navbarReducer(state = defaultState, action) { case TIME_OUT_FLY_IN: { return state.set("newNotifications", defaultState.get("newNotifications")); } - case FETCH_NOTIFICATION_COUNTS_SUCCESS: + case FETCH_NOTIFICATION_COUNT_SUCCESS: return state.merge({ unreadNotificationCount: action.unreadNotificationCount, notificationCount: action.notificationCount @@ -83,9 +86,7 @@ export default function navbarReducer(state = defaultState, action) { case HIDE_HISTORY: return state.set("showHistory", false); case SET_NOTIFICATIONS_PER_PAGE: - return state.set("notificationsPerPage", action.limit); - case SET_NOTIFICATION_OFFSET: - return state.set("notificationOffset", action.offset); + return state.set("notificationPageSize", action.notificationPageSize); case LOGOUT: return defaultState; default: diff --git a/frontend/src/pages/Workflows/WorkflowAssigneeContainer.js b/frontend/src/pages/Workflows/WorkflowAssigneeContainer.js index cd8e1c1cc..df7cb860d 100644 --- a/frontend/src/pages/Workflows/WorkflowAssigneeContainer.js +++ b/frontend/src/pages/Workflows/WorkflowAssigneeContainer.js @@ -28,10 +28,10 @@ class WorkflowAssigneeContainer extends Component { }; render() { - const { workflowItems, workflowitemId, users, title, disabled, workflowSortEnabled, status } = this.props; + const { workflowItems, workflowitemId, classes, users, title, disabled, workflowSortEnabled, status } = this.props; const assignee = this.getWorkflowAssignee(workflowItems, workflowitemId); return ( -
+
{ + return { + currentNotificationPage: state.getIn(["notifications", "currentNotificationPage"]), + numberOfNotificationPages: state.getIn(["notifications", "numberOfNotificationPages"]), + notificationPageSize: state.getIn(["notifications", "notificationPageSize"]) + }; +}; + function* callApi(func, ...args) { const token = yield select(getJwt); yield call(api.setAuthorizationHeader, token); @@ -549,17 +557,27 @@ export function* getEnvironmentSaga() { }); } -export function* fetchNotificationsSaga({ showLoading, offset, limit }) { - yield commonfetchNotifications(showLoading, offset, limit, FETCH_ALL_NOTIFICATIONS_SUCCESS); -} - -export function* commonfetchNotifications(showLoading, offset, limit, type) { +export function* fetchNotificationsSaga({ showLoading, notificationPage }) { yield execute(function*() { - // Get most recent items with negative offset - const { data } = yield callApi(api.fetchNotifications, 0 - offset - limit, limit); + const { data: notificationCountData } = yield callApi(api.fetchNotificationCounts); + const { notificationPageSize } = yield select(getNotificationState); + + const totalNotificationCount = notificationCountData.total; + + const numberOfNotificationPages = + notificationPageSize !== 0 ? Math.ceil(totalNotificationCount / notificationPageSize) : 1; + + const isLastNotificationPage = notificationPage + 1 === numberOfNotificationPages; + const offset = 0 - (notificationPage + 1) * notificationPageSize; + const itemsToFetch = isLastNotificationPage + ? totalNotificationCount - notificationPage * notificationPageSize + : notificationPageSize; + const { data } = yield callApi(api.fetchNotifications, offset, itemsToFetch); yield put({ - type, - notifications: data.notifications + type: FETCH_ALL_NOTIFICATIONS_SUCCESS, + notifications: data.notifications, + currentNotificationPage: notificationPage, + totalNotificationCount: totalNotificationCount }); }, showLoading); } @@ -568,14 +586,14 @@ export function* fetchNotificationCountsSaga({ showLoading }) { yield execute(function*() { const { data } = yield callApi(api.fetchNotificationCounts); yield put({ - type: FETCH_NOTIFICATION_COUNTS_SUCCESS, + type: FETCH_NOTIFICATION_COUNT_SUCCESS, unreadNotificationCount: data.unread, notificationCount: data.total }); }, showLoading); } -export function* markNotificationAsReadSaga({ notificationId, offset, limit }) { +export function* markNotificationAsReadSaga({ notificationId, notificationPage }) { yield execute(function*() { yield callApi(api.markNotificationAsRead, notificationId); yield put({ @@ -584,29 +602,27 @@ export function* markNotificationAsReadSaga({ notificationId, offset, limit }) { yield put({ type: FETCH_ALL_NOTIFICATIONS, showLoading: true, - offset, - limit + notificationPage }); yield put({ - type: FETCH_NOTIFICATION_COUNTS + type: FETCH_NOTIFICATION_COUNT }); }, true); } -export function* markMultipleNotificationsAsReadSaga({ notificationIds, offset, limit }) { +export function* markMultipleNotificationsAsReadSaga({ notificationIds, notificationPage }) { yield execute(function*() { yield callApi(api.markMultipleNotificationsAsRead, notificationIds); yield put({ - type: MARK_MULTIPLE_NOTIFICATION_AS_READ_SUCCESS + type: MARK_MULTIPLE_NOTIFICATIONS_AS_READ_SUCCESS }); yield put({ type: FETCH_ALL_NOTIFICATIONS, showLoading: true, - offset, - limit + notificationPage }); yield put({ - type: FETCH_NOTIFICATION_COUNTS + type: FETCH_NOTIFICATION_COUNT }); }, true); } @@ -1642,9 +1658,9 @@ export default function* rootSaga() { // Notifications yield takeEvery(FETCH_ALL_NOTIFICATIONS, fetchNotificationsSaga), - yield takeEvery(FETCH_NOTIFICATION_COUNTS, fetchNotificationCountsSaga), + yield takeEvery(FETCH_NOTIFICATION_COUNT, fetchNotificationCountsSaga), yield takeEvery(MARK_NOTIFICATION_AS_READ, markNotificationAsReadSaga), - yield takeEvery(MARK_MULTIPLE_NOTIFICATION_AS_READ, markMultipleNotificationsAsReadSaga), + yield takeEvery(MARK_MULTIPLE_NOTIFICATIONS_AS_READ, markMultipleNotificationsAsReadSaga), // Peers yield takeLatest(FETCH_ACTIVE_PEERS, fetchActivePeersSaga), From d09b1f413b88bdb1e3c7faa9ff53480e7ecd178c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20H=C3=B6ld?= Date: Tue, 21 May 2019 09:45:41 +0200 Subject: [PATCH 06/21] danger: Add check for missing e2e-tests Danger warns about missing E2E-tests for the following conditions: - There are changes in the frontend (except language changes) - AND there are no changes in the e2e tests - AND the pull request is not marked as trivial Additionally: Change condition from 'modified' to 'edited' (= 'created + 'modified') --- dangerfile.js | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/dangerfile.js b/dangerfile.js index dfe9e194f..abc9855f1 100644 --- a/dangerfile.js +++ b/dangerfile.js @@ -3,23 +3,42 @@ const { includes } = require("lodash"); const apiSources = danger.git.fileMatch("api/src/**/*.ts"); const blockchainSources = danger.git.fileMatch("blockchain/src/*"); -const frontendSources = danger.git.fileMatch("frontend/src/*"); +const frontendRootSources = danger.git.fileMatch("frontend/src/*.*"); +const frontendPageSources = danger.git.fileMatch("frontend/src/pages/*"); +const frontendLanguageSources = danger.git.fileMatch("frontend/src/pages/*"); const provisioningSources = danger.git.fileMatch("provisioning/src/*"); const e2eTestSources = danger.git.fileMatch("e2e-test/cypress/*"); const title = danger.github.pr.title.toLowerCase(); const trivialPR = title.includes("refactor"); const changelogChanges = includes(danger.git.modified_files, "CHANGELOG.md"); +const frontendChanges = + frontendRootSources.edited || + frontendPageSources.edited || + frontendLanguageSources.edited; // When there are app-changes and it's not a PR marked as trivial, expect there to be CHANGELOG changes. if ( - (apiSources.modified || - blockchainSources.modified || - frontendSources.modified || - provisioningSources.modified || - e2eTestSources.modified) && + (frontendChanges || + apiSources.edited || + blockchainSources.edited || + frontendRootSources.edited || + provisioningSources.edited || + e2eTestSources.edited) && !trivialPR && !changelogChanges ) { warn("No CHANGELOG added."); } + +// If there are changes in the UI (except language files) +// and PR is not marked as trivial, expect there to be E2E-test updates +if ( + (frontendRootSources.edited || frontendPageSources.edited) && + !e2eTestSources.modified && + !trivialPR +) { + warn( + "There were changes in the frontend, but no E2E-test was added or modified!" + ); +} From 1215f5de2a8d6cdac77d8b437aed332f061f7ac2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20H=C3=B6ld?= Date: Wed, 22 May 2019 16:31:18 +0200 Subject: [PATCH 07/21] ui: Don't display amount if no exchange rate is set When a workflow item is updated via the API from amount type "N/A" to "disbursed" or "allocated", the exchange rate does not need to be set immediately (it is only needed if the workflowitem is closed). Therefore it can happen that no exchange rate (or amount is set). If no amount or exchange rate is set, the amount field is left blank. --- CHANGELOG.md | 1 + frontend/src/pages/Workflows/WorkflowItem.js | 30 +++++++++++--------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89dc0e7cb..302e13a6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Fixed - Empty history displayed after API call is finished [#294](https://github.com/openkfw/TruBudget/issues/294) +- Workflowitem amount is only displayed if amount and exchange rate are available [#297](https://github.com/openkfw/TruBudget/issues/297) ## [1.0.1] - 2019-05-21 diff --git a/frontend/src/pages/Workflows/WorkflowItem.js b/frontend/src/pages/Workflows/WorkflowItem.js index 4ba5f68d8..dde35063f 100644 --- a/frontend/src/pages/Workflows/WorkflowItem.js +++ b/frontend/src/pages/Workflows/WorkflowItem.js @@ -253,25 +253,29 @@ const isWorkflowSelectable = (currentWorkflowSelectable, workflowSortEnabled, st }; const getAmountField = (amount, type, exchangeRate, sourceCurrency, targetCurrency) => { - let amountToShow = toAmountString(amount * exchangeRate, targetCurrency); + const amountToShow = toAmountString(amount * exchangeRate, targetCurrency); - const amountExplTitle = toAmountString(amount, sourceCurrency) + " x " + exchangeRate; + const amountExplanationTitle = toAmountString(amount, sourceCurrency) + " x " + exchangeRate; const amountExplaination = ( - + ); return ( -
-
{amountToShow}
-
- {fromAmountString(exchangeRate) !== 1 ? amountExplaination : null} -
+
+ {amount && exchangeRate ? ( +
+
{amountToShow}
+
+ {fromAmountString(exchangeRate) !== 1 ? amountExplaination : null} +
+
+ ) : null}
From 5ac2b67b507caa9093358efd71b62f92f37ef987 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20H=C3=B6ld?= Date: Fri, 24 May 2019 10:26:14 +0200 Subject: [PATCH 08/21] danger: Add warning for TODO and console log Two new warnings are added: - If there are additional console logs in the API - If there are new TODOs in any of the files Further changes: - Created async block to execute async calls like 'structuredDiffForFile' - Use Node 11 for Danger in order to use 'Array.prototype.flat()' - Created functions to extract additions, deletions and normal code from file changes --- .travis.yml | 8 ++++- dangerfile.js | 85 +++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 86 insertions(+), 7 deletions(-) diff --git a/.travis.yml b/.travis.yml index 1e3c317d6..c3363506a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,9 +13,15 @@ stages: - "10" cache: npm +.node-env-11: &node-env-11 + language: node_js + node_js: + - "11" + cache: npm + .dangerjs: &dangerjs stage: test - <<: *node-env + <<: *node-env-11 script: - npm run danger ci diff --git a/dangerfile.js b/dangerfile.js index dfe9e194f..b22ec582a 100644 --- a/dangerfile.js +++ b/dangerfile.js @@ -3,23 +3,96 @@ const { includes } = require("lodash"); const apiSources = danger.git.fileMatch("api/src/**/*.ts"); const blockchainSources = danger.git.fileMatch("blockchain/src/*"); -const frontendSources = danger.git.fileMatch("frontend/src/*"); +const frontendRootSources = danger.git.fileMatch("frontend/src/*.*"); +const frontendPageSources = danger.git.fileMatch("frontend/src/pages/*"); +const frontendLanguageSources = danger.git.fileMatch( + "frontend/src/languages/*" +); const provisioningSources = danger.git.fileMatch("provisioning/src/*"); const e2eTestSources = danger.git.fileMatch("e2e-test/cypress/*"); const title = danger.github.pr.title.toLowerCase(); const trivialPR = title.includes("refactor"); const changelogChanges = includes(danger.git.modified_files, "CHANGELOG.md"); +const frontendChanges = + frontendRootSources.edited || + frontendPageSources.edited || + frontendLanguageSources.edited; + +async function getChanges(filepath) { + const files = danger.git.fileMatch(filepath); + const { edited } = files.getKeyedPaths(paths => paths); + const chunksArray = await Promise.all( + edited.map(async path => { + const changes = await Promise.resolve( + danger.git.structuredDiffForFile(path) + ); + return changes; + }) + ); + const chunks = chunksArray.map(chunk => chunk.chunks).flat(); + const changes = chunks.map(chunk => chunk.changes).flat(); + return changes; +} + +function getContentByType(changes) { + const additions = changes + .filter(change => change.add) + .map(change => change.content); + const deletions = changes + .filter(change => change.del) + .map(change => change.content); + const normal = changes + .filter(change => change.normal) + .map(change => change.content); + return { + additions, + deletions, + normal + }; +} // When there are app-changes and it's not a PR marked as trivial, expect there to be CHANGELOG changes. if ( - (apiSources.modified || - blockchainSources.modified || - frontendSources.modified || - provisioningSources.modified || - e2eTestSources.modified) && + (frontendChanges || + apiSources.edited || + blockchainSources.edited || + frontendRootSources.edited || + provisioningSources.edited || + e2eTestSources.edited) && !trivialPR && !changelogChanges ) { warn("No CHANGELOG added."); } + +// If there are changes in the UI (except language files) +// and PR is not marked as trivial, expect there to be E2E-test updates +if ( + (frontendRootSources.edited || frontendPageSources.edited) && + !e2eTestSources.modified && + !trivialPR +) { + warn( + "There were changes in the frontend, but no E2E-test was added or modified!" + ); +} + +// Async part to check for changes in files +(async function() { + // Warn if there were console logs added in the API + const apiChanges = await getChanges("api/**/*.ts"); + const { additions: apiAdditions } = getContentByType(apiChanges); + + if (apiAdditions.some(addition => addition.includes("console.log"))) { + warn("There are new console logs in the API!"); + } + + // Warn if there was a TODO added in any file + const allChanges = await getChanges("**/*"); + const { additions: allAdditions } = getContentByType(allChanges); + + if (allAdditions.some(addition => addition.includes("TODO"))) { + warn("A new TODO was added."); + } +})(); From 1571a7407f9b79e2b274bd2eac884885d1f393ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20H=C3=B6ld?= Date: Fri, 24 May 2019 15:59:53 +0200 Subject: [PATCH 09/21] ui: Move organization string to 'common' Move the 'organization' string from 'strings.users.organization' to 'strings.common.organization'. --- frontend/src/languages/english.js | 2 +- frontend/src/languages/french.js | 2 +- frontend/src/languages/german.js | 2 +- frontend/src/languages/portuguese.js | 2 +- frontend/src/pages/Analytics/ProjectAnalytics.js | 2 +- frontend/src/pages/Analytics/SubProjectAnalytics.js | 2 +- frontend/src/pages/Common/Budget.js | 4 ++-- frontend/src/pages/Nodes/NodesTable.js | 2 +- frontend/src/pages/SubProjects/ProjectDetails.js | 2 +- frontend/src/pages/Users/UserDialogContent.js | 2 +- frontend/src/pages/Users/UsersTable.js | 2 +- frontend/src/pages/Workflows/SubProjectDetails.js | 2 +- 12 files changed, 13 insertions(+), 13 deletions(-) diff --git a/frontend/src/languages/english.js b/frontend/src/languages/english.js index 34d604616..3e0a48b84 100644 --- a/frontend/src/languages/english.js +++ b/frontend/src/languages/english.js @@ -48,6 +48,7 @@ const en = { not_disbursed: "Not disbursed", not_ok: "Not OK", open: "Open", + organization: "Organization", password: "Password", project: "Project", projected_budget: "Projected Budget", @@ -72,7 +73,6 @@ const en = { users: { full_name: "Full Name", new_user: "New User", - organization: "Organization", user_created: "User successfully created", users: "Users", add_user: "Add User", diff --git a/frontend/src/languages/french.js b/frontend/src/languages/french.js index 09d8fd149..184b80893 100644 --- a/frontend/src/languages/french.js +++ b/frontend/src/languages/french.js @@ -48,6 +48,7 @@ const fr = { not_disbursed: "Non décaissé", not_ok: "Pas OK", open: "Ouvert", + organization: "Organisation", password: "Mot de passe", project: "Projet", projected_budget: "Budget prévu", @@ -179,7 +180,6 @@ const fr = { users: { full_name: "Nom complet", new_user: "Nouvel utilisateur", - organization: "Organisation", user_created: "Utilisateur créé avec succès", users: "Utilisateurs", add_user: "Ajouter des utilisateurs", diff --git a/frontend/src/languages/german.js b/frontend/src/languages/german.js index 5b4957ffc..9c3168649 100644 --- a/frontend/src/languages/german.js +++ b/frontend/src/languages/german.js @@ -46,6 +46,7 @@ const de = { not_disbursed: "Nicht ausgezahlt", not_ok: "NOK", open: "Open", + organization: "German: Organization", password: "Passwort", project: "German: Project", projected_budget: "German: Projected Budget", @@ -177,7 +178,6 @@ const de = { users: { full_name: "German: Full Name", new_user: "German: New User", - organization: "German: Organization", user_created: "German: User successfully created", users: "German: Users", new_group: "German: New user group", diff --git a/frontend/src/languages/portuguese.js b/frontend/src/languages/portuguese.js index 6b09f23c3..16fc69ed3 100644 --- a/frontend/src/languages/portuguese.js +++ b/frontend/src/languages/portuguese.js @@ -48,6 +48,7 @@ const pt = { not_disbursed: "Não desembolsado", not_ok: "Não OK", open: "Abrir", + organization: "Organização", password: "Senha", project: "Portuguese: Project", projected_budget: "Portuguese: Projected Budget", @@ -72,7 +73,6 @@ const pt = { users: { full_name: "Nome completo", new_user: "Novo usuário", - organization: "Organização", user_created: "Portuguese: User successfully created", users: "Usuários", add_user: "Portuguese: Add Users", diff --git a/frontend/src/pages/Analytics/ProjectAnalytics.js b/frontend/src/pages/Analytics/ProjectAnalytics.js index 5bd85666d..51d882ad9 100644 --- a/frontend/src/pages/Analytics/ProjectAnalytics.js +++ b/frontend/src/pages/Analytics/ProjectAnalytics.js @@ -103,7 +103,7 @@ class ProjectAnalytics extends React.Component { - {strings.users.organization} + {strings.common.organization} {strings.amount} {strings.common.currency} {strings.workflow.exchange_rate} diff --git a/frontend/src/pages/Analytics/SubProjectAnalytics.js b/frontend/src/pages/Analytics/SubProjectAnalytics.js index ab31a5a75..8866cc6c9 100644 --- a/frontend/src/pages/Analytics/SubProjectAnalytics.js +++ b/frontend/src/pages/Analytics/SubProjectAnalytics.js @@ -87,7 +87,7 @@ class SubprojectAnalytics extends React.Component {
- {strings.users.organization} + {strings.common.organization} {strings.amount} {strings.common.currency} {strings.workflow.exchange_rate} diff --git a/frontend/src/pages/Common/Budget.js b/frontend/src/pages/Common/Budget.js index 0207a9f3a..b8386d772 100644 --- a/frontend/src/pages/Common/Budget.js +++ b/frontend/src/pages/Common/Budget.js @@ -93,7 +93,7 @@ export default class Budget extends React.Component {
- {strings.users.organization} + {strings.common.organization} {strings.common.projected_budget} {strings.common.actions} @@ -145,7 +145,7 @@ export default class Budget extends React.Component { this.setOrganization(e.target.value)} type="text" diff --git a/frontend/src/pages/Nodes/NodesTable.js b/frontend/src/pages/Nodes/NodesTable.js index b3b38a7a6..0435a5586 100644 --- a/frontend/src/pages/Nodes/NodesTable.js +++ b/frontend/src/pages/Nodes/NodesTable.js @@ -52,7 +52,7 @@ const NodesTable = ({ nodes, classes }) => {
- {strings.users.organization} + {strings.common.organization} {strings.nodesDashboard.nodes} {strings.nodesDashboard.access} diff --git a/frontend/src/pages/SubProjects/ProjectDetails.js b/frontend/src/pages/SubProjects/ProjectDetails.js index eb3d4be33..13d4eb286 100644 --- a/frontend/src/pages/SubProjects/ProjectDetails.js +++ b/frontend/src/pages/SubProjects/ProjectDetails.js @@ -90,7 +90,7 @@ const ProjectDetails = props => {
- {strings.users.organization} + {strings.common.organization} {strings.common.amount} {strings.common.currency} diff --git a/frontend/src/pages/Users/UserDialogContent.js b/frontend/src/pages/Users/UserDialogContent.js index 96c7cf239..e4447abf3 100644 --- a/frontend/src/pages/Users/UserDialogContent.js +++ b/frontend/src/pages/Users/UserDialogContent.js @@ -58,7 +58,7 @@ const UserDialogContent = ({ /> {strings.common.id} {strings.common.name} - {strings.users.organization} + {strings.common.organization} diff --git a/frontend/src/pages/Workflows/SubProjectDetails.js b/frontend/src/pages/Workflows/SubProjectDetails.js index 72ce29529..43fb2b181 100644 --- a/frontend/src/pages/Workflows/SubProjectDetails.js +++ b/frontend/src/pages/Workflows/SubProjectDetails.js @@ -116,7 +116,7 @@ const SubProjectDetails = ({
- {strings.users.organization} + {strings.common.organization} {strings.common.amount} {strings.common.currency} From 1fd634368ed4eca8043e8f38441913d3e6dc830c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20H=C3=B6ld?= Date: Tue, 28 May 2019 10:08:29 +0200 Subject: [PATCH 10/21] ui: Fix linting issues --- frontend/src/pages/Common/HistoryList.js | 3 +++ frontend/src/pages/Documents/DocumentOverview.js | 2 -- frontend/src/pages/Notifications/helper.js | 2 ++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/Common/HistoryList.js b/frontend/src/pages/Common/HistoryList.js index 8476fd13b..7ba2c5702 100644 --- a/frontend/src/pages/Common/HistoryList.js +++ b/frontend/src/pages/Common/HistoryList.js @@ -20,7 +20,9 @@ const styles = { export default function HistoryList({ events, nEventsTotal, hasMore, isLoading, getUserDisplayname }) { const eventItems = events.map((event, index) => { if (!(event.businessEvent && event.snapshot)) { + // eslint-disable-next-line no-console console.warn("The event does not have a business event or snapshot and will not be displayed", event); + return null; } const eventTime = event.businessEvent.time; @@ -167,6 +169,7 @@ function stringifyHistoryEvent(businessEvent, snapshot, getUserDisplayname) { displayName ); default: + // eslint-disable-next-line no-console console.log(`WARN: no handler for event type ${eventType}`); return eventType; } diff --git a/frontend/src/pages/Documents/DocumentOverview.js b/frontend/src/pages/Documents/DocumentOverview.js index 42a02135e..a59676cb2 100644 --- a/frontend/src/pages/Documents/DocumentOverview.js +++ b/frontend/src/pages/Documents/DocumentOverview.js @@ -77,12 +77,10 @@ class DocumentOverview extends Component { onChange={event => { if (event.target.files[0]) { const file = event.target.files[0]; - console.log("File: ", file); const reader = new FileReader(); reader.onloadend = e => { if (e.target.result !== undefined) { const dataUrl = e.target.result.split(";base64,")[1]; - console.log("dataUrl: ", dataUrl); this.props.validateDocument(hash, dataUrl, id); } }; diff --git a/frontend/src/pages/Notifications/helper.js b/frontend/src/pages/Notifications/helper.js index 6318758c6..9d2109a78 100644 --- a/frontend/src/pages/Notifications/helper.js +++ b/frontend/src/pages/Notifications/helper.js @@ -48,6 +48,7 @@ export const isAllowedToSee = notification => { export const intentMapping = notification => { const businessEvent = notification.businessEvent; if (!businessEvent) { + // eslint-disable-next-line no-console console.warn("Notification has no business event"); return ""; } @@ -55,6 +56,7 @@ export const intentMapping = notification => { const notificationMetaData = notification.metadata; if (!notificationMetaData) { + // eslint-disable-next-line no-console console.warn("Notification has no metadata"); return ""; } From f28f931ca0cb68b15f5efa7ff18f0c0b652f47fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20H=C3=B6ld?= Date: Tue, 28 May 2019 10:19:39 +0200 Subject: [PATCH 11/21] ui: Check for amount and echange rate correctly Since e.g. amount = 0 returns false, the condition whether to display the amount is changed to explicitly check for undefined values. --- frontend/src/pages/Workflows/WorkflowItem.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/Workflows/WorkflowItem.js b/frontend/src/pages/Workflows/WorkflowItem.js index dde35063f..ad1a5bd62 100644 --- a/frontend/src/pages/Workflows/WorkflowItem.js +++ b/frontend/src/pages/Workflows/WorkflowItem.js @@ -261,9 +261,10 @@ const getAmountField = (amount, type, exchangeRate, sourceCurrency, targetCurren ); + const isAmountDisplayed = amount !== undefined && exchangeRate !== undefined; return (
- {amount && exchangeRate ? ( + {isAmountDisplayed ? (
{amountToShow}
Date: Tue, 28 May 2019 10:54:08 +0200 Subject: [PATCH 12/21] danger: Move back to Node 10 The current LTS version of node is v10, therefore the change to node v11 was reversed. Since the 'flat' method is not available in this version, it was implemented as function in the danger file. --- .travis.yml | 8 +------- dangerfile.js | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/.travis.yml b/.travis.yml index c3363506a..1e3c317d6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,15 +13,9 @@ stages: - "10" cache: npm -.node-env-11: &node-env-11 - language: node_js - node_js: - - "11" - cache: npm - .dangerjs: &dangerjs stage: test - <<: *node-env-11 + <<: *node-env script: - npm run danger ci diff --git a/dangerfile.js b/dangerfile.js index b22ec582a..176739e4e 100644 --- a/dangerfile.js +++ b/dangerfile.js @@ -19,6 +19,16 @@ const frontendChanges = frontendPageSources.edited || frontendLanguageSources.edited; +function deepFlat(inputArray) { + return inputArray.reduce( + (accumulator, value) => + Array.isArray(value) + ? accumulator.concat(deepFlat(value)) + : accumulator.concat(value), + [] + ); +} + async function getChanges(filepath) { const files = danger.git.fileMatch(filepath); const { edited } = files.getKeyedPaths(paths => paths); @@ -30,8 +40,8 @@ async function getChanges(filepath) { return changes; }) ); - const chunks = chunksArray.map(chunk => chunk.chunks).flat(); - const changes = chunks.map(chunk => chunk.changes).flat(); + const chunks = deepFlat(chunksArray.map(chunk => chunk.chunks)); + const changes = deepFlat(chunks.map(chunk => chunk.changes)); return changes; } From 814fabf6795b780c241a6240cd6520cd24455472 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20H=C3=B6ld?= Date: Mon, 3 Jun 2019 09:13:21 +0200 Subject: [PATCH 13/21] Update axios version Due to security concerns the axios version was updated to 0.19.0. For further information see https://nvd.nist.gov/vuln/detail/CVE-2019-10742 --- api/package-lock.json | 49 +++++++++++++++++++++++++--------- api/package.json | 2 +- blockchain/package-lock.json | 24 ++++++++--------- blockchain/package.json | 2 +- e2e-test/package-lock.json | 24 ++++++++--------- e2e-test/package.json | 2 +- excel-export/package-lock.json | 38 +++++++++++++------------- excel-export/package.json | 2 +- frontend/package-lock.json | 46 ++++++++++++++++++++++++++----- frontend/package.json | 2 +- provisioning/package-lock.json | 25 ++++++++++------- provisioning/package.json | 2 +- 12 files changed, 140 insertions(+), 78 deletions(-) diff --git a/api/package-lock.json b/api/package-lock.json index 3389f9184..d71093a20 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -440,12 +440,19 @@ "dev": true }, "axios": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.18.0.tgz", - "integrity": "sha1-MtU+SFHv3AoRmTts0AB4nXDAUQI=", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.19.0.tgz", + "integrity": "sha512-1uvKqKQta3KBxIz14F2v06AEHZ/dIoeKfbTRkK1E5oqjDnuEerLmYTgJB5AiQZHJcljpg1TuRzdjDR06qNk0DQ==", "requires": { - "follow-redirects": "^1.3.0", - "is-buffer": "^1.1.5" + "follow-redirects": "1.5.10", + "is-buffer": "^2.0.2" + }, + "dependencies": { + "is-buffer": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.3.tgz", + "integrity": "sha512-U15Q7MXTuZlrbymiz95PJpZxu8IlipAp4dtS3wOdgPXx3mqBnslrWU14kxfHB+Py/+2PVKSr37dMAgM2A4uArw==" + } } }, "babel-code-frame": { @@ -2157,11 +2164,26 @@ "dev": true }, "follow-redirects": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.7.0.tgz", - "integrity": "sha512-m/pZQy4Gj287eNy94nivy5wchN3Kp+Q5WgUPNy5lJSZ3sgkVKSYV/ZChMAQVIgx1SqfZ2zBZtPA2YlXIWxxJOQ==", + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", + "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==", "requires": { - "debug": "^3.2.6" + "debug": "=3.1.0" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } } }, "for-in": { @@ -3216,7 +3238,8 @@ "is-buffer": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true }, "is-callable": { "version": "1.1.4", @@ -4350,7 +4373,7 @@ "dependencies": { "find-up": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "resolved": false, "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", "dev": true, "requires": { @@ -4359,7 +4382,7 @@ }, "locate-path": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "resolved": false, "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", "dev": true, "requires": { @@ -4388,7 +4411,7 @@ }, "p-locate": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "resolved": false, "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", "dev": true, "requires": { diff --git a/api/package.json b/api/package.json index a683e759b..10961beed 100644 --- a/api/package.json +++ b/api/package.json @@ -67,7 +67,7 @@ }, "keywords": [], "dependencies": { - "axios": "^0.18.0", + "axios": "^0.19.0", "bcryptjs": "^2.4.3", "body-parser": "^1.18.3", "fastify": "^1.14.4", diff --git a/blockchain/package-lock.json b/blockchain/package-lock.json index 9a19399e7..1ddba6e2c 100644 --- a/blockchain/package-lock.json +++ b/blockchain/package-lock.json @@ -390,12 +390,12 @@ "integrity": "sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==" }, "axios": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.17.1.tgz", - "integrity": "sha1-LY4+XQvb1zJ/kbyBT1xXZg+Bgk0=", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.19.0.tgz", + "integrity": "sha512-1uvKqKQta3KBxIz14F2v06AEHZ/dIoeKfbTRkK1E5oqjDnuEerLmYTgJB5AiQZHJcljpg1TuRzdjDR06qNk0DQ==", "requires": { - "follow-redirects": "^1.2.5", - "is-buffer": "^1.1.5" + "follow-redirects": "1.5.10", + "is-buffer": "^2.0.2" } }, "balanced-match": { @@ -1422,11 +1422,11 @@ } }, "follow-redirects": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.2.6.tgz", - "integrity": "sha512-FrMqZ/FONtHnbqO651UPpfRUVukIEwJhXMfdr/JWAmrDbeYBu773b1J6gdWDyRIj4hvvzQEHoEOTrdR8o6KLYA==", + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", + "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==", "requires": { - "debug": "^3.1.0" + "debug": "=3.1.0" } }, "foreground-child": { @@ -1771,9 +1771,9 @@ "dev": true }, "is-buffer": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.3.tgz", + "integrity": "sha512-U15Q7MXTuZlrbymiz95PJpZxu8IlipAp4dtS3wOdgPXx3mqBnslrWU14kxfHB+Py/+2PVKSr37dMAgM2A4uArw==" }, "is-fullwidth-code-point": { "version": "2.0.0", diff --git a/blockchain/package.json b/blockchain/package.json index 10ac0c03a..1887c2297 100644 --- a/blockchain/package.json +++ b/blockchain/package.json @@ -33,7 +33,7 @@ "dependencies": { "@kubernetes/client-node": "^0.8.1", "async-each": "^1.0.1", - "axios": "^0.17.1", + "axios": "^0.19.0", "body-parser": "^1.18.3", "chai": "^4.2.0", "express": "^4.16.3", diff --git a/e2e-test/package-lock.json b/e2e-test/package-lock.json index 5b177bb18..d58963690 100644 --- a/e2e-test/package-lock.json +++ b/e2e-test/package-lock.json @@ -235,12 +235,12 @@ "dev": true }, "axios": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.18.0.tgz", - "integrity": "sha1-MtU+SFHv3AoRmTts0AB4nXDAUQI=", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.19.0.tgz", + "integrity": "sha512-1uvKqKQta3KBxIz14F2v06AEHZ/dIoeKfbTRkK1E5oqjDnuEerLmYTgJB5AiQZHJcljpg1TuRzdjDR06qNk0DQ==", "requires": { - "follow-redirects": "^1.3.0", - "is-buffer": "^1.1.5" + "follow-redirects": "1.5.10", + "is-buffer": "^2.0.2" } }, "babel-runtime": { @@ -989,11 +989,11 @@ "dev": true }, "follow-redirects": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.1.tgz", - "integrity": "sha512-v9GI1hpaqq1ZZR6pBD1+kI7O24PhDvNGNodjS3MdcEqyrahCp8zbtpv+2B/krUnSmUH80lbAS7MrdeK5IylgKg==", + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", + "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==", "requires": { - "debug": "^3.1.0" + "debug": "=3.1.0" } }, "forever-agent": { @@ -1360,9 +1360,9 @@ } }, "is-buffer": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.3.tgz", + "integrity": "sha512-U15Q7MXTuZlrbymiz95PJpZxu8IlipAp4dtS3wOdgPXx3mqBnslrWU14kxfHB+Py/+2PVKSr37dMAgM2A4uArw==" }, "is-ci": { "version": "1.0.10", diff --git a/e2e-test/package.json b/e2e-test/package.json index 556981b10..e0f63c3fe 100644 --- a/e2e-test/package.json +++ b/e2e-test/package.json @@ -31,6 +31,6 @@ "e2etest": "cypress run" }, "dependencies": { - "axios": "^0.18.0" + "axios": "^0.19.0" } } diff --git a/excel-export/package-lock.json b/excel-export/package-lock.json index 8b7c7ce89..93333485f 100644 --- a/excel-export/package-lock.json +++ b/excel-export/package-lock.json @@ -70,12 +70,12 @@ } }, "axios": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.18.0.tgz", - "integrity": "sha1-MtU+SFHv3AoRmTts0AB4nXDAUQI=", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.19.0.tgz", + "integrity": "sha512-1uvKqKQta3KBxIz14F2v06AEHZ/dIoeKfbTRkK1E5oqjDnuEerLmYTgJB5AiQZHJcljpg1TuRzdjDR06qNk0DQ==", "requires": { - "follow-redirects": "^1.3.0", - "is-buffer": "^1.1.5" + "follow-redirects": "1.5.10", + "is-buffer": "^2.0.2" } }, "balanced-match": { @@ -240,11 +240,11 @@ } }, "debug": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", - "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", "requires": { - "ms": "^2.1.1" + "ms": "2.0.0" } }, "declare.js": { @@ -317,11 +317,11 @@ } }, "follow-redirects": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.7.0.tgz", - "integrity": "sha512-m/pZQy4Gj287eNy94nivy5wchN3Kp+Q5WgUPNy5lJSZ3sgkVKSYV/ZChMAQVIgx1SqfZ2zBZtPA2YlXIWxxJOQ==", + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", + "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==", "requires": { - "debug": "^3.2.6" + "debug": "=3.1.0" } }, "fs-constants": { @@ -388,9 +388,9 @@ "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" }, "is-buffer": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.3.tgz", + "integrity": "sha512-U15Q7MXTuZlrbymiz95PJpZxu8IlipAp4dtS3wOdgPXx3mqBnslrWU14kxfHB+Py/+2PVKSr37dMAgM2A4uArw==" }, "is-extended": { "version": "0.0.10", @@ -535,9 +535,9 @@ "integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==" }, "ms": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", - "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" }, "normalize-path": { "version": "3.0.0", diff --git a/excel-export/package.json b/excel-export/package.json index 595e8c1ed..9b6306d91 100644 --- a/excel-export/package.json +++ b/excel-export/package.json @@ -19,7 +19,7 @@ }, "homepage": "https://github.com/openkfw/TruBudget#readme", "dependencies": { - "axios": "^0.18.0", + "axios": "^0.19.0", "exceljs": "^1.9.0", "jwt-decode": "^2.2.0" }, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ecd17f6a9..938c2032d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -5076,12 +5076,40 @@ "dev": true }, "axios": { - "version": "0.16.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.16.2.tgz", - "integrity": "sha1-uk+S8XFn37q0CYN4VFS5rBScPG0=", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.19.0.tgz", + "integrity": "sha512-1uvKqKQta3KBxIz14F2v06AEHZ/dIoeKfbTRkK1E5oqjDnuEerLmYTgJB5AiQZHJcljpg1TuRzdjDR06qNk0DQ==", "requires": { - "follow-redirects": "^1.2.3", - "is-buffer": "^1.1.5" + "follow-redirects": "1.5.10", + "is-buffer": "^2.0.2" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + }, + "follow-redirects": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", + "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==", + "requires": { + "debug": "=3.1.0" + } + }, + "is-buffer": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.3.tgz", + "integrity": "sha512-U15Q7MXTuZlrbymiz95PJpZxu8IlipAp4dtS3wOdgPXx3mqBnslrWU14kxfHB+Py/+2PVKSr37dMAgM2A4uArw==" + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } } }, "axobject-query": { @@ -7476,6 +7504,7 @@ "version": "3.2.6", "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "dev": true, "requires": { "ms": "^2.1.1" } @@ -9069,6 +9098,7 @@ "version": "1.7.0", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.7.0.tgz", "integrity": "sha512-m/pZQy4Gj287eNy94nivy5wchN3Kp+Q5WgUPNy5lJSZ3sgkVKSYV/ZChMAQVIgx1SqfZ2zBZtPA2YlXIWxxJOQ==", + "dev": true, "requires": { "debug": "^3.2.6" } @@ -10030,7 +10060,8 @@ "is-buffer": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true }, "is-callable": { "version": "1.1.4", @@ -14251,7 +14282,8 @@ "ms": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", - "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", + "dev": true }, "multicast-dns": { "version": "6.2.3", diff --git a/frontend/package.json b/frontend/package.json index cf5634cd8..674e5d023 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,7 +20,7 @@ "@material-ui/core": "^3.9.3", "@material-ui/icons": "^3.0.2", "accounting": "^0.4.1", - "axios": "^0.16.0", + "axios": "^0.19.0", "chart.js": "^2.8.0", "dayjs": "^1.8.14", "downshift": "^2.0.16", diff --git a/provisioning/package-lock.json b/provisioning/package-lock.json index 80096e9bc..e6bdd62a9 100644 --- a/provisioning/package-lock.json +++ b/provisioning/package-lock.json @@ -75,12 +75,19 @@ "integrity": "sha1-ri1acpR38onWDdf5amMUoi3Wwio=" }, "axios": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.18.0.tgz", - "integrity": "sha1-MtU+SFHv3AoRmTts0AB4nXDAUQI=", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.19.0.tgz", + "integrity": "sha512-1uvKqKQta3KBxIz14F2v06AEHZ/dIoeKfbTRkK1E5oqjDnuEerLmYTgJB5AiQZHJcljpg1TuRzdjDR06qNk0DQ==", "requires": { - "follow-redirects": "^1.3.0", - "is-buffer": "^1.1.5" + "follow-redirects": "1.5.10", + "is-buffer": "^2.0.2" + }, + "dependencies": { + "is-buffer": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.3.tgz", + "integrity": "sha512-U15Q7MXTuZlrbymiz95PJpZxu8IlipAp4dtS3wOdgPXx3mqBnslrWU14kxfHB+Py/+2PVKSr37dMAgM2A4uArw==" + } } }, "balanced-match": { @@ -604,11 +611,11 @@ } }, "follow-redirects": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.0.tgz", - "integrity": "sha512-fdrt472/9qQ6Kgjvb935ig6vJCuofpBUD14f9Vb+SLlm7xIe4Qva5gey8EKtv8lp7ahE1wilg3xL1znpVGtZIA==", + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", + "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==", "requires": { - "debug": "^3.1.0" + "debug": "=3.1.0" } }, "for-in": { diff --git a/provisioning/package.json b/provisioning/package.json index 2ed373693..8328a3540 100644 --- a/provisioning/package.json +++ b/provisioning/package.json @@ -22,7 +22,7 @@ "test": "echo \"Error: no test specified\" && exit 1" }, "dependencies": { - "axios": "^0.18.0", + "axios": "^0.19.0", "nodemon": "^1.18.1" } } From b8e3098506a9a8497bef436d4cd1daec44139e05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20H=C3=B6ld?= Date: Tue, 28 May 2019 09:55:44 +0200 Subject: [PATCH 14/21] api: Add endpoint to change a user's password A new API endpoint was added that enables the change of a user's password. For this, the following has been added: * New intent 'user.changePassword' * New HTTP endpoint 'user.changePassword' * New service * New business event 'UserPasswordChanged' * New unit test * New business logic 'user_password_change' * New event 'user_password_changed' To be consistent with the rest of the API, the files for user handling (creating, getting, sourcing) have been refactored. --- CHANGELOG.md | 12 +- api/src/authz/intents.ts | 6 +- api/src/index.ts | 7 + api/src/service/cache2.ts | 2 + api/src/service/domain/business_event.ts | 4 +- .../domain/organization/user_create.ts | 31 ++- .../domain/organization/user_created.ts | 24 +++ .../domain/organization/user_eventsourcing.ts | 184 +++++++++++++----- .../organization/user_password_change.spec.ts | 112 +++++++++++ .../organization/user_password_change.ts | 69 +++++++ .../organization/user_password_changed.ts | 82 ++++++++ .../domain/organization/user_record.ts | 17 +- api/src/service/project_update.ts | 4 +- api/src/service/store.ts | 7 + api/src/service/user_create.ts | 11 +- api/src/service/user_password_change.ts | 25 +++ api/src/user_password_change.ts | 129 ++++++++++++ 17 files changed, 643 insertions(+), 83 deletions(-) create mode 100644 api/src/service/domain/organization/user_password_change.spec.ts create mode 100644 api/src/service/domain/organization/user_password_change.ts create mode 100644 api/src/service/domain/organization/user_password_changed.ts create mode 100644 api/src/service/user_password_change.ts create mode 100644 api/src/user_password_change.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ea6651e1c..53bf01945 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] ### Added + +- New API endpoint to change a user's password [#79](https://github.com/openkfw/TruBudget/issues/79) - Different background color for unread notifications [#300](https://github.com/openkfw/TruBudget/issues/300) ### Changed + - Notification displays name of parent project and subproject [#298](https://github.com/openkfw/TruBudget/issues/298) - Move 'Read All' button to the left side [#301](https://github.com/openkfw/TruBudget/issues/301) - Don't display view button if user is not allowed to see project/subproject [#302](https://github.com/openkfw/TruBudget/issues/302) @@ -28,18 +31,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Workflowitem amount is only displayed if amount and exchange rate are available [#297](https://github.com/openkfw/TruBudget/issues/297) -## [1.0.1] - 2019-05-21 - +## [1.0.1] - 2019-05-21 ### Changed - Increased Multichain Version to 2.0.1 [#273](https://github.com/openkfw/TruBudget/issues/273) - - - - ### Fixed - Correct number of history items is displayed when history drawer/list is opened [#275](https://github.com/openkfw/TruBudget/issues/275) @@ -50,8 +48,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - The link to the project/subproject in fly-in notifications correctly redirects the user [#285](https://github.com/openkfw/TruBudget/issues/285) - When a workflow item is assigned, the new assignee gets notified [#272](https://github.com/openkfw/TruBudget/issues/272) - - ## [1.0.0] - 2019-05-08 ### Added diff --git a/api/src/authz/intents.ts b/api/src/authz/intents.ts index f66fc6ba1..0ed092b2b 100644 --- a/api/src/authz/intents.ts +++ b/api/src/authz/intents.ts @@ -7,6 +7,7 @@ type Intent = | "global.createUser" | "global.createGroup" | "user.authenticate" + | "user.changePassword" | "user.view" | "group.addUser" | "group.removeUser" @@ -66,6 +67,7 @@ export const globalIntents: Intent[] = [ "global.createUser", "global.createGroup", "user.authenticate", + "user.changePassword", "network.registerNode", "network.list", "network.listActive", @@ -94,6 +96,7 @@ export const userAssignableIntents: Intent[] = [ "network.voteForPermission", "network.approveNewOrganization", "network.approveNewNodeForExistingOrganization", + "user.changePassword", ]; export const userDefaultIntents: Intent[] = [ @@ -102,7 +105,7 @@ export const userDefaultIntents: Intent[] = [ "network.listActive", ]; -export const userIntents: Intent[] = ["user.view", "user.authenticate"]; +export const userIntents: Intent[] = ["user.view", "user.authenticate", "user.changePassword"]; export const groupIntents: Intent[] = ["group.addUser", "group.removeUser"]; export const projectIntents: Intent[] = [ @@ -159,6 +162,7 @@ export const allIntents: Intent[] = [ "global.createUser", "global.createGroup", "user.authenticate", + "user.changePassword", "user.view", "group.addUser", "group.removeUser", diff --git a/api/src/index.ts b/api/src/index.ts index 2e8f66728..ee21e43cd 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -70,6 +70,7 @@ import * as SubprojectTraceEventsService from "./service/subproject_trace_events import * as SubprojectUpdateService from "./service/subproject_update"; import * as UserAuthenticateService from "./service/user_authenticate"; import * as UserCreateService from "./service/user_create"; +import * as UserPasswordChangeService from "./service/user_password_change"; import * as UserQueryService from "./service/user_query"; import * as WorkflowitemAssignService from "./service/workflowitem_assign"; import * as WorkflowitemCloseService from "./service/workflowitem_close"; @@ -98,6 +99,7 @@ import * as SubprojectViewHistoryAPIv2 from "./subproject_view_history_v2"; import * as UserAuthenticateAPI from "./user_authenticate"; import * as UserCreateAPI from "./user_create"; import * as UserListAPI from "./user_list"; +import * as UserPasswordChangeAPI from "./user_password_change"; import * as WorkflowitemAssignAPI from "./workflowitem_assign"; import * as WorkflowitemCloseAPI from "./workflowitem_close"; import * as WorkflowitemCreateAPI from "./workflowitem_create"; @@ -270,6 +272,11 @@ UserListAPI.addHttpHandler(server, URL_PREFIX, { listGroups: (ctx, issuer) => GroupQueryService.getGroups(db, ctx, issuer), }); +UserPasswordChangeAPI.addHttpHandler(server, URL_PREFIX, { + changeUserPassword: (ctx, issuer, reqData) => + UserPasswordChangeService.changeUserPassword(db, ctx, issuer, reqData), +}); + /* * APIs related to Groups */ diff --git a/api/src/service/cache2.ts b/api/src/service/cache2.ts index 9f260c0f5..fc8b271db 100644 --- a/api/src/service/cache2.ts +++ b/api/src/service/cache2.ts @@ -11,6 +11,7 @@ import * as GroupCreated from "./domain/organization/group_created"; import * as GroupMemberAdded from "./domain/organization/group_member_added"; import * as GroupMemberRemoved from "./domain/organization/group_member_removed"; import * as UserCreated from "./domain/organization/user_created"; +import * as UserPasswordChanged from "./domain/organization/user_password_changed"; import * as GlobalPermissionsGranted from "./domain/workflow/global_permission_granted"; import * as GlobalPermissionsRevoked from "./domain/workflow/global_permission_revoked"; import * as NotificationCreated from "./domain/workflow/notification_created"; @@ -528,6 +529,7 @@ const EVENT_PARSER_MAP = { subproject_projected_budget_updated: SubprojectProjectedBudgetUpdated.validate, subproject_updated: SubprojectUpdated.validate, user_created: UserCreated.validate, + user_password_changed: UserPasswordChanged.validate, workflowitem_assigned: WorkflowitemAssigned.validate, workflowitem_closed: WorkflowitemClosed.validate, workflowitem_created: WorkflowitemCreated.validate, diff --git a/api/src/service/domain/business_event.ts b/api/src/service/domain/business_event.ts index b237de55c..101cfebf8 100644 --- a/api/src/service/domain/business_event.ts +++ b/api/src/service/domain/business_event.ts @@ -6,6 +6,7 @@ import * as GroupMemberRemoved from "./organization/group_member_removed"; import * as GroupPermissionGranted from "./organization/group_permissions_granted"; import * as GroupPermissionRevoked from "./organization/group_permissions_revoked"; import * as UserCreated from "./organization/user_created"; +import * as UserPasswordChanged from "./organization/user_password_changed"; import * as GlobalPermissionsGranted from "./workflow/global_permission_granted"; import * as GlobalPermissionsRevoked from "./workflow/global_permission_revoked"; import * as NotificationCreated from "./workflow/notification_created"; @@ -21,7 +22,6 @@ import * as ProjectUpdated from "./workflow/project_updated"; import * as SubprojectAssigned from "./workflow/subproject_assigned"; import * as SubprojectClosed from "./workflow/subproject_closed"; import * as SubprojectCreated from "./workflow/subproject_created"; -import * as WorkflowitemsReordered from "./workflow/workflowitems_reordered"; import * as SubprojectPermissionGranted from "./workflow/subproject_permission_granted"; import * as SubprojectPermissionRevoked from "./workflow/subproject_permission_revoked"; import * as SubprojectProjectedBudgetDeleted from "./workflow/subproject_projected_budget_deleted"; @@ -33,6 +33,7 @@ import * as WorkflowitemCreated from "./workflow/workflowitem_created"; import * as WorkflowitemPermissionGranted from "./workflow/workflowitem_permission_granted"; import * as WorkflowitemPermissionRevoked from "./workflow/workflowitem_permission_revoked"; import * as WorkflowitemUpdated from "./workflow/workflowitem_updated"; +import * as WorkflowitemsReordered from "./workflow/workflowitems_reordered"; export type BusinessEvent = | GlobalPermissionsGranted.Event @@ -62,6 +63,7 @@ export type BusinessEvent = | SubprojectProjectedBudgetUpdated.Event | SubprojectUpdated.Event | UserCreated.Event + | UserPasswordChanged.Event | WorkflowitemAssigned.Event | WorkflowitemClosed.Event | WorkflowitemCreated.Event diff --git a/api/src/service/domain/organization/user_create.ts b/api/src/service/domain/organization/user_create.ts index b22ad0de8..3d4289a21 100644 --- a/api/src/service/domain/organization/user_create.ts +++ b/api/src/service/domain/organization/user_create.ts @@ -1,6 +1,6 @@ import Joi = require("joi"); -import Intent, { userDefaultIntents } from "../../../authz/intents"; +import Intent, { userDefaultIntents, userIntents } from "../../../authz/intents"; import { Ctx } from "../../../lib/ctx"; import * as Result from "../../../result"; import * as AdditionalData from "../additional_data"; @@ -8,15 +8,13 @@ import { BusinessEvent } from "../business_event"; import { InvalidCommand } from "../errors/invalid_command"; import { NotAuthorized } from "../errors/not_authorized"; import { PreconditionError } from "../errors/precondition_error"; -import { Permissions } from "../permissions"; +import * as GlobalPermissionGranted from "../workflow/global_permission_granted"; import { GlobalPermissions, identitiesAuthorizedFor } from "../workflow/global_permissions"; import { canAssumeIdentity } from "./auth_token"; import { KeyPair } from "./key_pair"; import { ServiceUser } from "./service_user"; import * as UserCreated from "./user_created"; -import { sourceUserRecords } from "./user_eventsourcing"; import * as UserRecord from "./user_record"; -import * as GlobalPermissionGranted from "../workflow/global_permission_granted"; export interface RequestData { userId: string; @@ -52,7 +50,7 @@ export async function createUser( creatingUser: ServiceUser, data: RequestData, repository: Repository, -): Promise<{ newEvents: BusinessEvent[]; errors: Error[] }> { +): Promise> { const source = ctx.source; const publisher = creatingUser.id; const eventTemplate = { @@ -62,17 +60,15 @@ export async function createUser( passwordHash: "...", address: "...", encryptedPrivKey: "...", - // TODO user permissions are currently managed in global-permissions: - permissions: {}, + permissions: userIntents.reduce((acc, intent) => { + return { ...acc, [intent]: [data.userId] }; + }, {}), additionalData: data.additionalData || {}, }; if (await repository.userExists(data.userId)) { const unfinishedBusinessEvent = UserCreated.createEvent(source, publisher, eventTemplate); - return { - newEvents: [], - errors: [new PreconditionError(ctx, unfinishedBusinessEvent, "user already exists")], - }; + return new PreconditionError(ctx, unfinishedBusinessEvent, "user already exists"); } // Check authorization (if not root): @@ -83,10 +79,7 @@ export async function createUser( canAssumeIdentity(creatingUser, identity), ); if (!isAuthorized) { - return { - newEvents: [], - errors: [new NotAuthorized({ ctx, userId: creatingUser.id, intent })], - }; + return new NotAuthorized({ ctx, userId: creatingUser.id, intent }); } } @@ -100,9 +93,9 @@ export async function createUser( const createEvent = UserCreated.createEvent(source, publisher, eventTemplate); // Check that the event is valid by trying to "apply" it: - const { errors } = sourceUserRecords(ctx, [createEvent]); - if (errors.length > 0) { - return { newEvents: [], errors: [new InvalidCommand(ctx, createEvent, errors)] }; + const result = UserCreated.createFrom(ctx, createEvent); + if (Result.isErr(result)) { + return new InvalidCommand(ctx, createEvent, [result]); } // Create events that'll grant default permissions to the user: @@ -110,5 +103,5 @@ export async function createUser( GlobalPermissionGranted.createEvent(ctx.source, publisher, intent, createEvent.user.id), ); - return { newEvents: [createEvent, ...defaultPermissionGrantedEvents], errors: [] }; + return [createEvent, ...defaultPermissionGrantedEvents]; } diff --git a/api/src/service/domain/organization/user_created.ts b/api/src/service/domain/organization/user_created.ts index 845af6cb2..907938654 100644 --- a/api/src/service/domain/organization/user_created.ts +++ b/api/src/service/domain/organization/user_created.ts @@ -1,8 +1,10 @@ import Joi = require("joi"); import { VError } from "verror"; +import { Ctx } from "../../../lib/ctx"; import * as Result from "../../../result"; import * as AdditionalData from "../additional_data"; +import { EventSourcingError } from "../errors/event_sourcing_error"; import * as UserRecord from "../organization/user_record"; import { Permissions, permissionsSchema } from "../permissions"; import { Identity } from "./identity"; @@ -77,3 +79,25 @@ export function validate(input: any): Result.Type { const { error, value } = Joi.validate(input, schema); return !error ? value : error; } + +export function createFrom(ctx: Ctx, event: Event): Result.Type { + const initialData = event.user; + + const user: UserRecord.UserRecord = { + id: initialData.id, + createdAt: event.time, + displayName: initialData.displayName, + organization: initialData.organization, + passwordHash: initialData.passwordHash, + address: initialData.address, + encryptedPrivKey: initialData.encryptedPrivKey, + permissions: initialData.permissions, + log: [], + additionalData: initialData.additionalData, + }; + + return Result.mapErr( + UserRecord.validate(user), + error => new EventSourcingError({ ctx, event, target: user }, error), + ); +} diff --git a/api/src/service/domain/organization/user_eventsourcing.ts b/api/src/service/domain/organization/user_eventsourcing.ts index ab6f7e623..afde7198e 100644 --- a/api/src/service/domain/organization/user_eventsourcing.ts +++ b/api/src/service/domain/organization/user_eventsourcing.ts @@ -1,73 +1,165 @@ +import { VError } from "verror"; + import { Ctx } from "../../../lib/ctx"; +import deepcopy from "../../../lib/deepcopy"; import * as Result from "../../../result"; import { BusinessEvent } from "../business_event"; import { EventSourcingError } from "../errors/event_sourcing_error"; import * as UserCreated from "./user_created"; +import * as UserPasswordChanged from "./user_password_changed"; import * as UserRecord from "./user_record"; import { UserTraceEvent } from "./user_trace_event"; export function sourceUserRecords( ctx: Ctx, events: BusinessEvent[], -): { users: UserRecord.UserRecord[]; errors: EventSourcingError[] } { - const users = new Map(); - const errors: EventSourcingError[] = []; + origin?: Map, +): { users: UserRecord.UserRecord[]; errors: Error[] } { + const users = + origin === undefined + ? new Map() + : new Map(origin); + const errors: Error[] = []; + for (const event of events) { - apply(ctx, users, event, errors); + if (!event.type.startsWith("user_")) { + continue; + } + + const user = sourceEvent(ctx, event, users); + if (Result.isErr(user)) { + errors.push(user); + } else { + user.log.push(newTraceEvent(user, event)); + users.set(user.id, user); + } } + return { users: [...users.values()], errors }; } -function apply( +function newTraceEvent(user: UserRecord.UserRecord, event: BusinessEvent): UserTraceEvent { + return { + entityId: user.id, + entityType: "user", + businessEvent: event, + snapshot: { + displayName: user.displayName, + }, + }; +} + +function sourceEvent( ctx: Ctx, - users: Map, event: BusinessEvent, - errors: EventSourcingError[], -) { - if (event.type === "user_created") { - handleUserCreated(ctx, users, event, errors); + users: Map, +): Result.Type { + const userId = getUserId(event); + let user: Result.Type; + if (Result.isOk(userId)) { + // The event refers to an existing user, so + // the user should have been initialized already. + + user = get(users, userId); + if (Result.isErr(user)) { + return new VError(`user ID ${userId} found in event ${event.type} is invalid`); + } + + user = newUserFromEvent(ctx, user, event); + if (Result.isErr(user)) { + return user; // <- event-sourcing error + } + } else { + // The event does not refer to a user ID, so it must be a creation event: + if (event.type !== "user_created") { + return new VError( + `event ${event.type} is not of type "user_created" but also ` + + "does not include a user ID", + ); + } + + user = UserCreated.createFrom(ctx, event); + if (Result.isErr(user)) { + return new VError(user, "could not create user from event"); + } } + + return user; } -function handleUserCreated( - ctx: Ctx, +function get( users: Map, - userCreated: UserCreated.Event, - errors: EventSourcingError[], -) { - const initialData = userCreated.user; - - let user = users.get(initialData.id); - if (user !== undefined) return; - - user = { - id: initialData.id, - createdAt: userCreated.time, - displayName: initialData.displayName, - organization: initialData.organization, - passwordHash: initialData.passwordHash, - address: initialData.address, - encryptedPrivKey: initialData.encryptedPrivKey, - permissions: initialData.permissions, - log: [], - additionalData: initialData.additionalData, - }; + userId: UserRecord.Id, +): Result.Type { + const user = users.get(userId); + if (user === undefined) { + return new VError(`user ${userId} not yet initialized`); + } + return user; +} - const result = UserRecord.validate(user); - if (Result.isErr(result)) { - errors.push(new EventSourcingError({ ctx, event: userCreated }, result)); - return; +function getUserId(event: BusinessEvent): Result.Type { + switch (event.type) { + case "user_password_changed": + return event.user.id; + + default: + return new VError(`cannot find user ID in event of type ${event.type}`); } +} - const traceEvent: UserTraceEvent = { - entityId: initialData.id, - entityType: "user", - businessEvent: userCreated, - snapshot: { - displayName: user.displayName, - }, - }; - user.log.push(traceEvent); +/** Returns a new user with the given event applied, or an error. */ +export function newUserFromEvent( + ctx: Ctx, + user: UserRecord.UserRecord, + event: BusinessEvent, +): Result.Type { + const eventModule = getEventModule(event); + + // Ensure that we never modify user or event in-place by passing copies. When + // copying the user, its event log is omitted for performance reasons. + const eventCopy = deepcopy(event); + const userCopy = copyUserExceptLog(user); + + try { + // Apply the event to the copied user: + const mutation = eventModule.mutate(userCopy, eventCopy); + if (Result.isErr(mutation)) { + throw mutation; + } + + // Validate the modified user: + const validation = UserRecord.validate(userCopy); + if (Result.isErr(validation)) { + throw validation; + } + + // Restore the event log: + userCopy.log = user.log; + + // Return the modified (and validated) user: + return userCopy; + } catch (error) { + return new EventSourcingError({ ctx, event, target: user }, error); + } +} + +type EventModule = { + mutate: (user: UserRecord.UserRecord, event: BusinessEvent) => Result.Type; +}; +function getEventModule(event: BusinessEvent): EventModule { + switch (event.type) { + case "user_password_changed": + return UserPasswordChanged; + + default: + throw new VError(`unknown user event ${event.type}`); + } +} - users.set(initialData.id, user); +function copyUserExceptLog(user: UserRecord.UserRecord): UserRecord.UserRecord { + const { log, ...tmp } = user; + const copy = deepcopy(tmp); + (copy as any).log = []; + return copy as UserRecord.UserRecord; } diff --git a/api/src/service/domain/organization/user_password_change.spec.ts b/api/src/service/domain/organization/user_password_change.spec.ts new file mode 100644 index 000000000..09e0d00ee --- /dev/null +++ b/api/src/service/domain/organization/user_password_change.spec.ts @@ -0,0 +1,112 @@ +import { assert } from "chai"; + +import { Ctx } from "../../../lib/ctx"; +import * as Result from "../../../result"; +import { hashPassword, isPasswordMatch } from "../../password"; +import { NotAuthorized } from "../errors/not_authorized"; +import { ServiceUser } from "../organization/service_user"; +import { newUserFromEvent } from "./user_eventsourcing"; +import { changeUserPassword, RequestData } from "./user_password_change"; +import { UserRecord } from "./user_record"; + +const ctx: Ctx = { requestId: "", source: "test" }; +const root: ServiceUser = { id: "root", groups: [] }; +const alice: ServiceUser = { id: "alice", groups: ["alice_and_bob", "alice_and_bob_and_charlie"] }; +const bob: ServiceUser = { id: "bob", groups: ["alice_and_bob", "alice_and_bob_and_charlie"] }; + +const dummy = "dummy"; +const passwordChangeUser: UserRecord = { + id: dummy, + createdAt: new Date().toISOString(), + displayName: dummy, + organization: dummy, + passwordHash: dummy, + address: dummy, + encryptedPrivKey: dummy, + permissions: {}, + log: [], + additionalData: {}, +}; + +const requestData: RequestData = { + userId: dummy, + newPassword: "newtest", +}; + +const baseRepository = { + getUser: () => Promise.resolve(passwordChangeUser), + hash: () => Promise.resolve("passwordHash"), +}; + +describe("change a user's password: authorization", () => { + it("Without the user.changePassword permission, a user cannot change a password", async () => { + const result = await changeUserPassword(ctx, alice, requestData, { + ...baseRepository, + }); + assert.instanceOf(result, NotAuthorized); + }); + + it("The root user doesn't need permission to change a user's password", async () => { + const result = await changeUserPassword(ctx, root, requestData, { + ...baseRepository, + }); + if (Result.isErr(result)) { + throw result; + } + assert.isTrue(Result.isOk(result)); + assert.isTrue(result.length > 0); + }); + + it("A user can change another user's password if the correct permissions are given", async () => { + const result = await changeUserPassword(ctx, alice, requestData, { + ...baseRepository, + getUser: () => + Promise.resolve({ + ...passwordChangeUser, + permissions: { "user.changePassword": [alice.id] }, + }), + }); + if (Result.isErr(result)) { + throw result; + } + assert.isTrue(Result.isOk(result)); + assert.isTrue(result.length > 0); + }); +}); + +describe("change a user's password: how modifications are applied", () => { + it("Changes the user's password immediately", async () => { + const oldPassword = "passwordTest"; + const oldPasswordHash = await hashPassword(oldPassword); + const newPassword = "newPassword"; + const reqData = { + userId: dummy, + newPassword, + }; + + assert.isTrue(await isPasswordMatch(oldPassword, oldPasswordHash)); + const result = await changeUserPassword(ctx, alice, reqData, { + ...baseRepository, + hash: async passwordPlaintext => hashPassword(passwordPlaintext), + getUser: async () => + Promise.resolve({ + ...passwordChangeUser, + permissions: { "user.changePassword": [alice.id] }, + }), + }); + if (Result.isErr(result)) { + throw result; + } + + const sourcedUser = result.reduce( + (user, event) => newUserFromEvent(ctx, user, event), + passwordChangeUser, + ); + if (Result.isErr(sourcedUser)) { + throw sourcedUser; + } + assert.isTrue(Result.isOk(result)); + assert.isFalse(await isPasswordMatch(oldPassword, sourcedUser.passwordHash)); + assert.isTrue(await isPasswordMatch(newPassword, sourcedUser.passwordHash)); + }); +}); diff --git a/api/src/service/domain/organization/user_password_change.ts b/api/src/service/domain/organization/user_password_change.ts new file mode 100644 index 000000000..66681df67 --- /dev/null +++ b/api/src/service/domain/organization/user_password_change.ts @@ -0,0 +1,69 @@ +import Joi = require("joi"); + +import Intent from "../../../authz/intents"; +import { Ctx } from "../../../lib/ctx"; +import * as Result from "../../../result"; +import { BusinessEvent } from "../business_event"; +import { InvalidCommand } from "../errors/invalid_command"; +import { NotAuthorized } from "../errors/not_authorized"; +import { PreconditionError } from "../errors/precondition_error"; +import { ServiceUser } from "./service_user"; +import * as UserEventSourcing from "./user_eventsourcing"; +import * as UserPasswordChanged from "./user_password_changed"; +import * as UserRecord from "./user_record"; + +export interface RequestData { + userId: string; + newPassword: string; +} + +const requestDataSchema = Joi.object({ + userId: UserRecord.idSchema.required(), + newPassword: Joi.string().required(), +}); + +export function validate(input: any): Result.Type { + const { value, error } = Joi.validate(input, requestDataSchema); + return !error ? value : error; +} + +interface Repository { + getUser(userId: string): Promise>; + hash(plaintext: string): Promise; +} + +export async function changeUserPassword( + ctx: Ctx, + issuer: ServiceUser, + data: RequestData, + repository: Repository, +): Promise> { + const source = ctx.source; + const publisher = issuer.id; + const passwordChanged = UserPasswordChanged.createEvent(source, publisher, { + id: data.userId, + passwordHash: await repository.hash(data.newPassword), + }); + + const userResult = await repository.getUser(data.userId); + if (Result.isErr(userResult)) { + return new PreconditionError(ctx, passwordChanged, "Error getting user"); + } + const user = userResult; + + // Check authorization (if not root): + if (issuer.id !== "root") { + const intent: Intent = "user.changePassword"; + const isAuthorized = UserRecord.permits(user, issuer, [intent]); + if (!isAuthorized) { + return new NotAuthorized({ ctx, userId: issuer.id, intent }); + } + } + + const result = UserEventSourcing.newUserFromEvent(ctx, user, passwordChanged); + if (Result.isErr(result)) { + return new InvalidCommand(ctx, passwordChanged, [result]); + } + + return [passwordChanged]; +} diff --git a/api/src/service/domain/organization/user_password_changed.ts b/api/src/service/domain/organization/user_password_changed.ts new file mode 100644 index 000000000..757d994b6 --- /dev/null +++ b/api/src/service/domain/organization/user_password_changed.ts @@ -0,0 +1,82 @@ +import Joi = require("joi"); +import { VError } from "verror"; + +import * as Result from "../../../result"; +import * as UserRecord from "../organization/user_record"; +import { Identity } from "./identity"; + +type eventTypeType = "user_password_changed"; +const eventType: eventTypeType = "user_password_changed"; + +interface InitialData { + id: UserRecord.Id; + passwordHash: string; +} + +const initialDataSchema = Joi.object({ + id: UserRecord.idSchema.required(), + passwordHash: Joi.string().required(), +}); + +export interface Event { + type: eventTypeType; + source: string; + time: string; // ISO timestamp + publisher: Identity; + user: InitialData; +} + +export const schema = Joi.object({ + type: Joi.valid(eventType).required(), + source: Joi.string() + .allow("") + .required(), + time: Joi.date() + .iso() + .required(), + publisher: Joi.string().required(), + user: initialDataSchema.required(), +}); + +export function createEvent( + source: string, + publisher: Identity, + user: InitialData, + time: string = new Date().toISOString(), +): Event { + const event = { + type: eventType, + source, + publisher, + time, + user, + }; + const validationResult = validate(event); + if (Result.isErr(validationResult)) { + throw new VError(validationResult, `not a valid ${eventType} event`); + } + return event; +} + +export function validate(input: any): Result.Type { + const { error, value } = Joi.validate(input, schema); + return !error ? value : error; +} + +/** + * Applies the event to the given user, or returns an error. + * + * When an error is returned (or thrown), any already applied modifications are + * discarded. + * + * This function is not expected to validate its changes; instead, the modified user + * is automatically validated when obtained using + * `user_eventsourcing.ts`:`newUserFromEvent`. + */ +export function mutate(user: UserRecord.UserRecord, event: Event): Result.Type { + if (event.type !== "user_password_changed") { + throw new VError(`illegal event type: ${event.type}`); + } + + user.passwordHash = event.user.passwordHash; +} diff --git a/api/src/service/domain/organization/user_record.ts b/api/src/service/domain/organization/user_record.ts index d0931156d..5b8c992f2 100644 --- a/api/src/service/domain/organization/user_record.ts +++ b/api/src/service/domain/organization/user_record.ts @@ -1,8 +1,12 @@ import Joi = require("joi"); +import Intent from "../../../authz/intents"; import * as Result from "../../../result"; import * as AdditionalData from "../additional_data"; import { Permissions, permissionsSchema } from "../permissions"; +import { canAssumeIdentity } from "./auth_token"; +import { Identity } from "./identity"; +import { ServiceUser } from "./service_user"; import { UserTraceEvent, userTraceEventSchema } from "./user_trace_event"; export type Id = string; @@ -38,7 +42,18 @@ const schema = Joi.object({ additionalData: AdditionalData.schema.required(), }); -export function validate(input: any): Result.Type { +export function validate(input: any): Result.Type { const { error, value } = Joi.validate(input, schema); return !error ? value : error; } + +export function permits(user: UserRecord, actingUser: ServiceUser, intents: Intent[]): boolean { + const eligibleIdentities: Identity[] = intents.reduce((acc: Identity[], intent: Intent) => { + const eligibles = user.permissions[intent] || []; + return acc.concat(eligibles); + }, []); + const hasPermission = eligibleIdentities.some(identity => + canAssumeIdentity(actingUser, identity), + ); + return hasPermission; +} diff --git a/api/src/service/project_update.ts b/api/src/service/project_update.ts index 4701f5447..d95782a68 100644 --- a/api/src/service/project_update.ts +++ b/api/src/service/project_update.ts @@ -17,8 +17,8 @@ export async function updateProject( ): Promise { const result = await Cache.withCache(conn, ctx, async cache => ProjectUpdate.updateProject(ctx, serviceUser, projectId, requestData, { - getProject: async projectId => { - return cache.getProject(projectId); + getProject: async pId => { + return cache.getProject(pId); }, getUsersForIdentity: async identity => { return GroupQuery.resolveUsers(conn, ctx, serviceUser, identity); diff --git a/api/src/service/store.ts b/api/src/service/store.ts index f92e31390..ff782871e 100644 --- a/api/src/service/store.ts +++ b/api/src/service/store.ts @@ -58,6 +58,13 @@ export async function store(conn: ConnToken, ctx: Ctx, event: BusinessEvent): Pr event, }); + case "user_password_changed": + return writeTo(conn, ctx, { + stream: "users", + keys: [event.user.id], + event, + }); + case "workflowitems_reordered": return writeTo(conn, ctx, { stream: event.projectId, diff --git a/api/src/service/user_create.ts b/api/src/service/user_create.ts index fa0f7a6b7..975e73241 100644 --- a/api/src/service/user_create.ts +++ b/api/src/service/user_create.ts @@ -2,6 +2,7 @@ import { Ctx } from "../lib/ctx"; import logger from "../lib/logger"; import { encrypt } from "../lib/symmetricCrypto"; import { getOrganizationAddress } from "../organization/organization"; +import * as Result from "../result"; import { ConnToken } from "./conn"; import { createkeypairs } from "./createkeypairs"; import * as AuthToken from "./domain/organization/auth_token"; @@ -21,25 +22,25 @@ export async function createUser( serviceUser: ServiceUser, requestData: UserCreate.RequestData, ): Promise { - const { newEvents, errors } = await UserCreate.createUser(ctx, serviceUser, requestData, { + const result = await UserCreate.createUser(ctx, serviceUser, requestData, { getGlobalPermissions: async () => getGlobalPermissions(conn, ctx, serviceUser), userExists: async userId => userExists(conn, ctx, serviceUser, userId), createKeyPair: async () => createkeypairs(conn.multichainClient), hash: async plaintext => hashPassword(plaintext), encrypt: async plaintext => encrypt(organizationSecret, plaintext), }); - if (errors.length > 0) return Promise.reject(errors); - if (!newEvents.length) { + if (Result.isErr(result)) return Promise.reject(result); + if (!result.length) { const msg = "failed to create user"; logger.error({ ctx, serviceUser, requestData }, msg); throw new Error(msg); } - for (const event of newEvents) { + for (const event of result) { await store(conn, ctx, event); } - const { users } = sourceUserRecords(ctx, newEvents); + const { users } = sourceUserRecords(ctx, result); if (users.length !== 1) { throw new Error(`Expected new events to yield exactly one user, got: ${JSON.stringify(users)}`); } diff --git a/api/src/service/user_password_change.ts b/api/src/service/user_password_change.ts new file mode 100644 index 000000000..dda602101 --- /dev/null +++ b/api/src/service/user_password_change.ts @@ -0,0 +1,25 @@ +import { Ctx } from "../lib/ctx"; +import * as Result from "../result"; +import { ConnToken } from "./conn"; +import { ServiceUser } from "./domain/organization/service_user"; +import * as UserPasswordChange from "./domain/organization/user_password_change"; +import { hashPassword } from "./password"; +import { store } from "./store"; +import * as UserQuery from "./user_query"; + +export async function changeUserPassword( + conn: ConnToken, + ctx: Ctx, + serviceUser: ServiceUser, + requestData: UserPasswordChange.RequestData, +): Promise { + const result = await UserPasswordChange.changeUserPassword(ctx, serviceUser, requestData, { + getUser: () => UserQuery.getUser(conn, ctx, serviceUser, requestData.userId), + hash: passwordPlainText => hashPassword(passwordPlainText), + }); + if (Result.isErr(result)) return Promise.reject(result); + + for (const event of result) { + await store(conn, ctx, event); + } +} diff --git a/api/src/user_password_change.ts b/api/src/user_password_change.ts new file mode 100644 index 000000000..d030f0476 --- /dev/null +++ b/api/src/user_password_change.ts @@ -0,0 +1,129 @@ +import { FastifyInstance } from "fastify"; +import Joi = require("joi"); +import { VError } from "verror"; + +import { toHttpError } from "./http_errors"; +import * as NotAuthenticated from "./http_errors/not_authenticated"; +import { AuthenticatedRequest } from "./httpd/lib"; +import { Ctx } from "./lib/ctx"; +import * as Result from "./result"; +import { ServiceUser } from "./service/domain/organization/service_user"; +import * as UserChangePassword from "./service/domain/organization/user_password_change"; + +interface RequestBodyV1 { + apiVersion: "1.0"; + data: { + userId: string; + newPassword: string; + }; +} + +const requestBodyV1Schema = Joi.object({ + apiVersion: Joi.valid("1.0").required(), + data: Joi.object({ + userId: Joi.string().required(), + newPassword: Joi.string().required(), + }).required(), +}); + +type RequestBody = RequestBodyV1; +const requestBodySchema = Joi.alternatives([requestBodyV1Schema]); + +function validateRequestBody(body: any): Result.Type { + const { error, value } = Joi.validate(body, requestBodySchema); + return !error ? value : error; +} + +function mkSwaggerSchema(server: FastifyInstance) { + return { + beforeHandler: [(server as any).authenticate], + schema: { + description: "Change a user's password", + tags: ["user"], + summary: "Change a user's password", + security: [ + { + bearerToken: [], + }, + ], + body: { + type: "object", + required: ["apiVersion", "data"], + properties: { + apiVersion: { type: "string", example: "1.0" }, + data: { + type: "object", + required: ["userId", "newPassword"], + properties: { + userId: { type: "string", example: "aSmith" }, + newPassword: { type: "string", example: "123456" }, + }, + }, + }, + }, + response: { + 200: { + description: "successful response", + type: "object", + properties: { + apiVersion: { type: "string", example: "1.0" }, + data: { + type: "object", + }, + }, + }, + 401: NotAuthenticated.schema, + }, + }, + }; +} + +interface Service { + changeUserPassword( + ctx: Ctx, + serviceUser: ServiceUser, + requestData: UserChangePassword.RequestData, + ): Promise; +} + +export function addHttpHandler(server: FastifyInstance, urlPrefix: string, service: Service) { + server.post(`${urlPrefix}/user.changePassword`, mkSwaggerSchema(server), (request, reply) => { + const ctx: Ctx = { requestId: request.id, source: "http" }; + + const serviceUser: ServiceUser = { + id: (request as AuthenticatedRequest).user.userId, + groups: (request as AuthenticatedRequest).user.groups, + }; + + const bodyResult = validateRequestBody(request.body); + + if (Result.isErr(bodyResult)) { + const { code, body } = toHttpError( + new VError(bodyResult, "failed to change user's password"), + ); + reply.status(code).send(body); + return; + } + + const data = bodyResult.data; + const reqData = { + userId: data.userId, + newPassword: data.newPassword, + }; + + service + .changeUserPassword(ctx, serviceUser, reqData) + .then(() => { + const code = 200; + const body = { + apiVersion: "1.0", + data: {}, + }; + reply.status(code).send(body); + }) + .catch(err => { + const { code, body } = toHttpError(err); + reply.status(code).send(body); + }); + }); +} From 90b8585276c8c6f2dd450fdd672279d3f1235e63 Mon Sep 17 00:00:00 2001 From: lennartploen <48890587+lennartploen@users.noreply.github.com> Date: Thu, 6 Jun 2019 10:59:40 +0200 Subject: [PATCH 15/21] Changed "Projected budget of project {0} was deleted" etc --- frontend/src/languages/french.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/languages/french.js b/frontend/src/languages/french.js index 184b80893..0ab31c1e1 100644 --- a/frontend/src/languages/french.js +++ b/frontend/src/languages/french.js @@ -284,8 +284,8 @@ const fr = { project_createSubproject: "Une nouvelle composante a été créée pour le projet. {0}", project_intent_grantPermission: "Les autorisations pour le projet {0} ont changé", project_intent_revokePermission: "Les autorisations pour le projet {0} ont changé", - project_projected_budget_deleted: "FRENCH: Projected budget of project {0} was deleted", - project_projected_budget_updated: "FRENCH: Projected budget of project {0} was updated", + project_projected_budget_deleted: "Le budget prévu du projet {0} a été supprimé", + project_projected_budget_updated: "Le budget prévu du projet {0} a été mis à jour", project_update: "Projet {0} a été actualisé", project_updated: "Projet {0} a été actualisé", review_transaction: "Vous êtes chargé d'examiner la transaction {0}", @@ -298,8 +298,8 @@ const fr = { subproject_createWorkflowitem: "Un nouveau élément du workflow a été crée pour le ous-projet {0}", subproject_intent_grantPermission: "Les autorisations pour la composante {0} ont changé", subproject_intent_revokePermission: "Les autorisations pour la composante {0} ont changé", - subproject_projected_budget_deleted: "FRENCH: Projected budget of subproject {0} was deleted", - subproject_projected_budget_updated: "FRENCH: Projected budget of subproject {0} was updated", + subproject_projected_budget_deleted: "Le budget prévu du la composante {0} a été supprimé", + subproject_projected_budget_updated: "Le budget prévu du la composante {0} a été mis à jour", subproject_reorderWorkflowitems: "Les éléments du workflow {0} ont été restauré", subproject_update: "Composante {0} a été actualisée", subproject_updated: "Composante {0} a été actualisée", From b21e0c685d91d7dff9f2060cfe347f2936d19f32 Mon Sep 17 00:00:00 2001 From: lennartploen <48890587+lennartploen@users.noreply.github.com> Date: Thu, 6 Jun 2019 11:09:55 +0200 Subject: [PATCH 16/21] French Translations 2 --- frontend/src/languages/french.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/languages/french.js b/frontend/src/languages/french.js index 184b80893..aaaf9ecef 100644 --- a/frontend/src/languages/french.js +++ b/frontend/src/languages/french.js @@ -67,7 +67,7 @@ const fr = { view: "Vue", workflowItem: "Élement de workflow", history_end: "French: Last event reached", - no_history: "French: No events" + no_history: "Aucun évenement" }, login: { From 84a5a4df7ccb1e6dabeb2be1fdc812d2250d8b92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20H=C3=B6ld?= Date: Thu, 6 Jun 2019 10:47:14 +0200 Subject: [PATCH 17/21] Update french translations Update french translations that were not translated yet and add new string for projected budget distribution in the subproject analytics. --- frontend/src/languages/english.js | 1 + frontend/src/languages/french.js | 21 ++++++++++--------- frontend/src/languages/german.js | 1 + frontend/src/languages/portuguese.js | 1 + .../pages/Analytics/SubProjectAnalytics.js | 2 +- 5 files changed, 15 insertions(+), 11 deletions(-) diff --git a/frontend/src/languages/english.js b/frontend/src/languages/english.js index 3e0a48b84..0bd2af31e 100644 --- a/frontend/src/languages/english.js +++ b/frontend/src/languages/english.js @@ -217,6 +217,7 @@ const en = { disbursed_budget_ratio: "Disbursed Budget Ratio", project_analytics: "Project Analytics", projected_budget_ratio: "Projected Budget Ratio", + projected_budgets_distribution: "Projected Budgets Distribution", subproject_analytics: "Subproject Analytics", total: "Total:", total_budget_distribution: "Total Budget Distribution" diff --git a/frontend/src/languages/french.js b/frontend/src/languages/french.js index 0650e362c..a444bec8c 100644 --- a/frontend/src/languages/french.js +++ b/frontend/src/languages/french.js @@ -66,8 +66,8 @@ const fr = { username: "Nom d'utilisateur", view: "Vue", workflowItem: "Élement de workflow", - history_end: "French: Last event reached", - no_history: "Aucun évenement" + no_history: "Aucun évenement", + history_end: "Dernier évènement atteint" }, login: { @@ -117,7 +117,7 @@ const fr = { subproject_close_not_allowed: "Vous n'êtes pas autorisé à fermer la composante", subproject_comment: "Commentaire de la composante", subproject_completion_string: "{0} cal {1} van", - subproject_currency: "Devise", + subproject_currency: "Devise la composante", subproject_edit_title: "Modifier la composante", subproject_permissions_title: "Définir les autorisations pour la composante", subproject_preview: "Aperçu de la composante", @@ -171,10 +171,10 @@ const fr = { workflow_type_transaction: "Transaction", workflow_type_workflow: "Workflow", workflow_upload_document: "Télécharger", - workflowitem_details: "FRENCH: Workflowitem item details", - workflowitem_details_documents: "FRENCH: Documents", - workflowitem_details_history: "FRENCH: History", - workflowitem_details_overview: "FRENCH: Overview" + workflowitem_details: "Détails du workflow item", + workflowitem_details_documents: "Documents", + workflowitem_details_history: "Historique", + workflowitem_details_overview: "Sommaire" }, users: { @@ -218,6 +218,7 @@ const fr = { converted_amount: "Montant converti", disbursed_budget_ratio: "Taux de décaissement(décaissé/alloué)", project_analytics: "Analyse de projet", + projected_budgets_distribution: "Répartition du budget total", projected_budget_ratio: "Taux d’estimation du budget(estimé/total)", subproject_analytics: "Analyse de la composante", total: "Total:", @@ -295,7 +296,7 @@ const fr = { subproject_assigned: "Composante {0} vous a été assignée", subproject_close: "Composante {0} a été fermée", subproject_closed: "Composante {0} a été fermée", - subproject_createWorkflowitem: "Un nouveau élément du workflow a été crée pour le ous-projet {0}", + subproject_createWorkflowitem: "Un nouveau élément du workflow a été crée pour la composante {0}", subproject_intent_grantPermission: "Les autorisations pour la composante {0} ont changé", subproject_intent_revokePermission: "Les autorisations pour la composante {0} ont changé", subproject_projected_budget_deleted: "Le budget prévu du la composante {0} a été supprimé", @@ -341,9 +342,9 @@ const fr = { project_revokePermission_details: "{0} a revoqué l'autorisation {1} à {2} de {3}", project_update: "{0} a modifié le projet {1} ", sort: "Déplacé {0} après {1}", - subproject_assign: "{0} a assigné le projet {1} à {2}", + subproject_assign: "{0} a assigné la composante {1} à {2}", subproject_close: "{0} a terminé la composante {1}", - subproject_create: "{0} a créé un projet {1}", + subproject_create: "{0} a créé une composante {1}", subproject_createWorkflowitem: "{0} a crée l'élément de workflow {1}", subproject_grantPermission: "{0} a modifié l'autorisation {1} à {2}", subproject_grantPermission_details: "{0} a modifié l'autorisation {1} à {2} de {3}", diff --git a/frontend/src/languages/german.js b/frontend/src/languages/german.js index 9c3168649..8dcc6732c 100644 --- a/frontend/src/languages/german.js +++ b/frontend/src/languages/german.js @@ -214,6 +214,7 @@ const de = { assigned_budget_ratio: "Zugewiesene Budgetquote", available_unspent_budget: "Verfügbares Budget", converted_amount: "Umgerechneter Betrag", + projected_budgets_distribution: "Verteilung des geplanten Budgets", disbursed_budget_ratio: "Ausgezahlte Budgetquote", project_analytics: "Projekt Analyse", projected_budget_ratio: "Projezierte Budgetquote", diff --git a/frontend/src/languages/portuguese.js b/frontend/src/languages/portuguese.js index 16fc69ed3..b726d56d0 100644 --- a/frontend/src/languages/portuguese.js +++ b/frontend/src/languages/portuguese.js @@ -215,6 +215,7 @@ const pt = { assigned_budget_ratio: "Rácio do Orçamento Atribuído", available_unspent_budget: "Orçamento Disponível", converted_amount: "quantidade convertida", + projected_budgets_distribution: "PORTUGUESE: Projected Budgets Distribution", disbursed_budget_ratio: "Rácio do Orçamento Desembolsado", project_analytics: "Project Analytics", projected_budget_ratio: "Rácio do Orçamento Projetado", diff --git a/frontend/src/pages/Analytics/SubProjectAnalytics.js b/frontend/src/pages/Analytics/SubProjectAnalytics.js index 8866cc6c9..49dc590ae 100644 --- a/frontend/src/pages/Analytics/SubProjectAnalytics.js +++ b/frontend/src/pages/Analytics/SubProjectAnalytics.js @@ -201,7 +201,7 @@ const Dashboard = ({ indicatedCurrency, projectedBudgets, projectedBudget, assig return (
Date: Tue, 11 Jun 2019 09:34:49 +0200 Subject: [PATCH 18/21] danger: Modify file paths To correctly check for missing E2E-tests, the file path has been modified to correctly include all changes in the E2E folder. --- dangerfile.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dangerfile.js b/dangerfile.js index 176739e4e..dfc4c734b 100644 --- a/dangerfile.js +++ b/dangerfile.js @@ -4,12 +4,12 @@ const { includes } = require("lodash"); const apiSources = danger.git.fileMatch("api/src/**/*.ts"); const blockchainSources = danger.git.fileMatch("blockchain/src/*"); const frontendRootSources = danger.git.fileMatch("frontend/src/*.*"); -const frontendPageSources = danger.git.fileMatch("frontend/src/pages/*"); +const frontendPageSources = danger.git.fileMatch("frontend/src/pages/**/*.js"); const frontendLanguageSources = danger.git.fileMatch( "frontend/src/languages/*" ); -const provisioningSources = danger.git.fileMatch("provisioning/src/*"); -const e2eTestSources = danger.git.fileMatch("e2e-test/cypress/*"); +const provisioningSources = danger.git.fileMatch("provisioning/src/**/*"); +const e2eTestSources = danger.git.fileMatch("e2e-test/cypress/**/*"); const title = danger.github.pr.title.toLowerCase(); const trivialPR = title.includes("refactor"); From bebf8dbcf69446bc0a4fab24c271ba0d67ea59a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20H=C3=B6ld?= Date: Mon, 3 Jun 2019 15:21:21 +0200 Subject: [PATCH 19/21] api: Add user permission endpoints The following endpoints have been added: - user.intent.grantPermission - user.intent.revokePermission - user.intent.listPermissions Unit tests for 'user.intent.grantPermission' and 'user.intent.grantPermission' have been added. Rework based on review - Remove new intents from global intents - Adapt unit tests for user_permission_grant and user_permission_revoke - Rename 'getUser' to 'getTargetUser' --- CHANGELOG.md | 1 + api/src/authz/intents.ts | 17 ++- api/src/index.ts | 21 +++ api/src/service/cache2.ts | 4 + api/src/service/domain/business_event.ts | 4 + .../domain/organization/user_eventsourcing.ts | 35 +++-- .../user_permission_grant.spec.ts | 129 ++++++++++++++++ .../organization/user_permission_grant.ts | 66 ++++++++ .../organization/user_permission_granted.ts | 86 +++++++++++ .../user_permission_revoke.spec.ts | 128 ++++++++++++++++ .../organization/user_permission_revoke.ts | 63 ++++++++ .../organization/user_permission_revoked.ts | 92 ++++++++++++ api/src/service/project_permission_grant.ts | 4 +- api/src/service/project_permission_revoke.ts | 4 +- api/src/service/store.ts | 8 + api/src/service/user_permission_grant.ts | 38 +++++ api/src/service/user_permission_revoke.ts | 33 ++++ api/src/service/user_permissions_list.ts | 28 ++++ api/src/user_permission_grant.ts | 142 ++++++++++++++++++ api/src/user_permission_revoke.ts | 142 ++++++++++++++++++ api/src/user_permissions_list.ts | 105 +++++++++++++ 21 files changed, 1131 insertions(+), 19 deletions(-) create mode 100644 api/src/service/domain/organization/user_permission_grant.spec.ts create mode 100644 api/src/service/domain/organization/user_permission_grant.ts create mode 100644 api/src/service/domain/organization/user_permission_granted.ts create mode 100644 api/src/service/domain/organization/user_permission_revoke.spec.ts create mode 100644 api/src/service/domain/organization/user_permission_revoke.ts create mode 100644 api/src/service/domain/organization/user_permission_revoked.ts create mode 100644 api/src/service/user_permission_grant.ts create mode 100644 api/src/service/user_permission_revoke.ts create mode 100644 api/src/service/user_permissions_list.ts create mode 100644 api/src/user_permission_grant.ts create mode 100644 api/src/user_permission_revoke.ts create mode 100644 api/src/user_permissions_list.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 53bf01945..8efd2fa5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added - New API endpoint to change a user's password [#79](https://github.com/openkfw/TruBudget/issues/79) +- New API endpoints to grant, revoke and list permissions [#310](https://github.com/openkfw/TruBudget/issues/310) - Different background color for unread notifications [#300](https://github.com/openkfw/TruBudget/issues/300) ### Changed diff --git a/api/src/authz/intents.ts b/api/src/authz/intents.ts index 0ed092b2b..18d8393d5 100644 --- a/api/src/authz/intents.ts +++ b/api/src/authz/intents.ts @@ -9,6 +9,9 @@ type Intent = | "user.authenticate" | "user.changePassword" | "user.view" + | "user.intent.listPermissions" + | "user.intent.grantPermission" + | "user.intent.revokePermission" | "group.addUser" | "group.removeUser" | "project.intent.listPermissions" @@ -67,7 +70,6 @@ export const globalIntents: Intent[] = [ "global.createUser", "global.createGroup", "user.authenticate", - "user.changePassword", "network.registerNode", "network.list", "network.listActive", @@ -105,7 +107,15 @@ export const userDefaultIntents: Intent[] = [ "network.listActive", ]; -export const userIntents: Intent[] = ["user.view", "user.authenticate", "user.changePassword"]; +export const userIntents: Intent[] = [ + "user.view", + "user.authenticate", + "user.changePassword", + "user.intent.listPermissions", + "user.intent.grantPermission", + "user.intent.revokePermission", +]; + export const groupIntents: Intent[] = ["group.addUser", "group.removeUser"]; export const projectIntents: Intent[] = [ @@ -163,6 +173,9 @@ export const allIntents: Intent[] = [ "global.createGroup", "user.authenticate", "user.changePassword", + "user.intent.listPermissions", + "user.intent.grantPermission", + "user.intent.revokePermission", "user.view", "group.addUser", "group.removeUser", diff --git a/api/src/index.ts b/api/src/index.ts index ee21e43cd..7d2db40b8 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -71,6 +71,9 @@ import * as SubprojectUpdateService from "./service/subproject_update"; import * as UserAuthenticateService from "./service/user_authenticate"; import * as UserCreateService from "./service/user_create"; import * as UserPasswordChangeService from "./service/user_password_change"; +import * as UserPermissionGrantService from "./service/user_permission_grant"; +import * as UserPermissionRevokeService from "./service/user_permission_revoke"; +import * as UserPermissionsListService from "./service/user_permissions_list"; import * as UserQueryService from "./service/user_query"; import * as WorkflowitemAssignService from "./service/workflowitem_assign"; import * as WorkflowitemCloseService from "./service/workflowitem_close"; @@ -100,6 +103,9 @@ import * as UserAuthenticateAPI from "./user_authenticate"; import * as UserCreateAPI from "./user_create"; import * as UserListAPI from "./user_list"; import * as UserPasswordChangeAPI from "./user_password_change"; +import * as UserPermissionGrantAPI from "./user_permission_grant"; +import * as UserPermissionRevokeAPI from "./user_permission_revoke"; +import * as UserPermissionsListAPI from "./user_permissions_list"; import * as WorkflowitemAssignAPI from "./workflowitem_assign"; import * as WorkflowitemCloseAPI from "./workflowitem_close"; import * as WorkflowitemCreateAPI from "./workflowitem_create"; @@ -277,6 +283,21 @@ UserPasswordChangeAPI.addHttpHandler(server, URL_PREFIX, { UserPasswordChangeService.changeUserPassword(db, ctx, issuer, reqData), }); +UserPermissionGrantAPI.addHttpHandler(server, URL_PREFIX, { + grantUserPermission: (ctx, granter, userId, grantee, intent) => + UserPermissionGrantService.grantUserPermission(db, ctx, granter, userId, grantee, intent), +}); + +UserPermissionRevokeAPI.addHttpHandler(server, URL_PREFIX, { + revokeUserPermission: (ctx, revoker, userId, revokee, intent) => + UserPermissionRevokeService.revokeUserPermission(db, ctx, revoker, userId, revokee, intent), +}); + +UserPermissionsListAPI.addHttpHandler(server, URL_PREFIX, { + getUserPermissions: (ctx, user, userId) => + UserPermissionsListService.getUserPermissions(db, ctx, user, userId), +}); + /* * APIs related to Groups */ diff --git a/api/src/service/cache2.ts b/api/src/service/cache2.ts index fc8b271db..4e199a582 100644 --- a/api/src/service/cache2.ts +++ b/api/src/service/cache2.ts @@ -12,6 +12,8 @@ import * as GroupMemberAdded from "./domain/organization/group_member_added"; import * as GroupMemberRemoved from "./domain/organization/group_member_removed"; import * as UserCreated from "./domain/organization/user_created"; import * as UserPasswordChanged from "./domain/organization/user_password_changed"; +import * as UserPermissionsGranted from "./domain/organization/user_permission_granted"; +import * as UserPermissionsRevoked from "./domain/organization/user_permission_revoked"; import * as GlobalPermissionsGranted from "./domain/workflow/global_permission_granted"; import * as GlobalPermissionsRevoked from "./domain/workflow/global_permission_revoked"; import * as NotificationCreated from "./domain/workflow/notification_created"; @@ -530,6 +532,8 @@ const EVENT_PARSER_MAP = { subproject_updated: SubprojectUpdated.validate, user_created: UserCreated.validate, user_password_changed: UserPasswordChanged.validate, + user_permission_granted: UserPermissionsGranted.validate, + user_permission_revoked: UserPermissionsRevoked.validate, workflowitem_assigned: WorkflowitemAssigned.validate, workflowitem_closed: WorkflowitemClosed.validate, workflowitem_created: WorkflowitemCreated.validate, diff --git a/api/src/service/domain/business_event.ts b/api/src/service/domain/business_event.ts index 101cfebf8..d1288c01a 100644 --- a/api/src/service/domain/business_event.ts +++ b/api/src/service/domain/business_event.ts @@ -7,6 +7,8 @@ import * as GroupPermissionGranted from "./organization/group_permissions_grante import * as GroupPermissionRevoked from "./organization/group_permissions_revoked"; import * as UserCreated from "./organization/user_created"; import * as UserPasswordChanged from "./organization/user_password_changed"; +import * as UserPermissionGranted from "./organization/user_permission_granted"; +import * as UserPermissionRevoked from "./organization/user_permission_revoked"; import * as GlobalPermissionsGranted from "./workflow/global_permission_granted"; import * as GlobalPermissionsRevoked from "./workflow/global_permission_revoked"; import * as NotificationCreated from "./workflow/notification_created"; @@ -64,6 +66,8 @@ export type BusinessEvent = | SubprojectUpdated.Event | UserCreated.Event | UserPasswordChanged.Event + | UserPermissionGranted.Event + | UserPermissionRevoked.Event | WorkflowitemAssigned.Event | WorkflowitemClosed.Event | WorkflowitemCreated.Event diff --git a/api/src/service/domain/organization/user_eventsourcing.ts b/api/src/service/domain/organization/user_eventsourcing.ts index afde7198e..a3c731e25 100644 --- a/api/src/service/domain/organization/user_eventsourcing.ts +++ b/api/src/service/domain/organization/user_eventsourcing.ts @@ -7,6 +7,8 @@ import { BusinessEvent } from "../business_event"; import { EventSourcingError } from "../errors/event_sourcing_error"; import * as UserCreated from "./user_created"; import * as UserPasswordChanged from "./user_password_changed"; +import * as UserPermissionGranted from "./user_permission_granted"; +import * as UserPermissionRevoked from "./user_permission_revoked"; import * as UserRecord from "./user_record"; import { UserTraceEvent } from "./user_trace_event"; @@ -27,6 +29,7 @@ export function sourceUserRecords( } const user = sourceEvent(ctx, event, users); + if (Result.isErr(user)) { errors.push(user); } else { @@ -102,12 +105,31 @@ function getUserId(event: BusinessEvent): Result.Type { switch (event.type) { case "user_password_changed": return event.user.id; + case "user_permission_granted": + case "user_permission_revoked": + return event.userId; default: return new VError(`cannot find user ID in event of type ${event.type}`); } } +type EventModule = { + mutate: (user: UserRecord.UserRecord, event: BusinessEvent) => Result.Type; +}; +function getEventModule(event: BusinessEvent): EventModule { + switch (event.type) { + case "user_password_changed": + return UserPasswordChanged; + case "user_permission_granted": + return UserPermissionGranted; + case "user_permission_revoked": + return UserPermissionRevoked; + default: + throw new VError(`unknown user event ${event.type}`); + } +} + /** Returns a new user with the given event applied, or an error. */ export function newUserFromEvent( ctx: Ctx, @@ -144,19 +166,6 @@ export function newUserFromEvent( } } -type EventModule = { - mutate: (user: UserRecord.UserRecord, event: BusinessEvent) => Result.Type; -}; -function getEventModule(event: BusinessEvent): EventModule { - switch (event.type) { - case "user_password_changed": - return UserPasswordChanged; - - default: - throw new VError(`unknown user event ${event.type}`); - } -} - function copyUserExceptLog(user: UserRecord.UserRecord): UserRecord.UserRecord { const { log, ...tmp } = user; const copy = deepcopy(tmp); diff --git a/api/src/service/domain/organization/user_permission_grant.spec.ts b/api/src/service/domain/organization/user_permission_grant.spec.ts new file mode 100644 index 000000000..f742eaac8 --- /dev/null +++ b/api/src/service/domain/organization/user_permission_grant.spec.ts @@ -0,0 +1,129 @@ +import { assert } from "chai"; + +import { Ctx } from "../../../lib/ctx"; +import * as Result from "../../../result"; +import { NotAuthorized } from "../errors/not_authorized"; +import { ServiceUser } from "../organization/service_user"; +import { newUserFromEvent } from "./user_eventsourcing"; +import { grantUserPermission } from "./user_permission_grant"; +import { UserRecord } from "./user_record"; + +const ctx: Ctx = { requestId: "", source: "test" }; +const root: ServiceUser = { id: "root", groups: [] }; +const alice: ServiceUser = { id: "alice", groups: ["alice_and_bob", "alice_and_bob_and_charlie"] }; +const bob: ServiceUser = { id: "bob", groups: ["alice_and_bob", "alice_and_bob_and_charlie"] }; +const charlie: ServiceUser = { id: "charlie", groups: ["alice_and_bob_and_charlie"] }; + +const grantIntent = "user.intent.grantPermission"; +const userId = "dummy"; +const baseUser: UserRecord = { + id: userId, + createdAt: new Date().toISOString(), + organization: "dummy", + displayName: "dummy", + passwordHash: "password", + permissions: {}, + address: "12345", + encryptedPrivKey: "encrypted", + log: [], + additionalData: {}, +}; + +const baseRepository = { + getTargetUser: async () => baseUser, +}; + +describe("Granting user permissions: permissions", () => { + it("Without the user.intent.grantPermission permission, a user cannot grant user permissions", async () => { + const result = await grantUserPermission(ctx, alice, userId, bob.id, grantIntent, { + ...baseRepository, + }); + + // NotAuthorized error due to the missing permissions: + assert.isTrue(Result.isErr(result), "Alice is not authorized to grant this permission"); + assert.instanceOf(result, NotAuthorized, "The error is of the type 'Not Authorized'"); + }); + + it("The root user can always grant user permissions", async () => { + const result = await grantUserPermission(ctx, root, userId, bob.id, grantIntent, { + ...baseRepository, + }); + + assert.isTrue(Result.isOk(result)); + }); +}); + +describe("Granting user permissions: updates", () => { + it("The permission is granted if the user has the correct permissions", async () => { + const permissionTestUser: UserRecord = { + ...baseUser, + permissions: { "user.intent.grantPermission": [alice.id] }, + }; + const result = await grantUserPermission(ctx, alice, userId, bob.id, grantIntent, { + getTargetUser: async () => Promise.resolve(permissionTestUser), + }); + if (Result.isErr(result)) { + throw result; + } + + const userAfterGrantingPermission = result.reduce( + (user, event) => newUserFromEvent(ctx, user, event), + permissionTestUser, + ); + if (Result.isErr(userAfterGrantingPermission)) { + throw userAfterGrantingPermission; + } + + assert.isTrue(Result.isOk(result), "Alice is authorized to grant this permission"); + // Bob now has the permission + assert.isTrue( + userAfterGrantingPermission.permissions["user.intent.grantPermission"]!.some( + x => x === bob.id, + ), + ); + // Alice still has the permission + assert.isTrue( + userAfterGrantingPermission.permissions["user.intent.grantPermission"]!.some( + x => x === alice.id, + ), + ); + }); + + it("An existing permission is granted, nothing happens", async () => { + const testUser: UserRecord = { + id: userId, + createdAt: new Date().toISOString(), + organization: "dummy", + displayName: "dummy", + passwordHash: "password", + permissions: { "user.intent.grantPermission": [alice.id] }, + address: "12345", + encryptedPrivKey: "encrypted", + log: [], + additionalData: {}, + }; + const result = await grantUserPermission(ctx, alice, userId, alice.id, grantIntent, { + getTargetUser: async () => Promise.resolve(testUser), + }); + if (Result.isErr(result)) { + throw result; + } + + const userAfterGrantingPermission = result.reduce( + (user, event) => newUserFromEvent(ctx, user, event), + testUser, + ); + if (Result.isErr(userAfterGrantingPermission)) { + throw userAfterGrantingPermission; + } + + assert.isTrue(Result.isOk(result), "Alice is authorized to grant this permission"); + assert.deepEqual(result, []); + // Alice still has the permission + assert.isTrue( + userAfterGrantingPermission.permissions["user.intent.grantPermission"]!.some( + x => x === alice.id, + ), + ); + }); +}); diff --git a/api/src/service/domain/organization/user_permission_grant.ts b/api/src/service/domain/organization/user_permission_grant.ts new file mode 100644 index 000000000..eb1adb0a6 --- /dev/null +++ b/api/src/service/domain/organization/user_permission_grant.ts @@ -0,0 +1,66 @@ +import isEqual = require("lodash.isequal"); + +import Intent from "../../../authz/intents"; +import { Ctx } from "../../../lib/ctx"; +import * as Result from "../../../result"; +import { BusinessEvent } from "../business_event"; +import { InvalidCommand } from "../errors/invalid_command"; +import { NotAuthorized } from "../errors/not_authorized"; +import { NotFound } from "../errors/not_found"; +import { Identity } from "./identity"; +import { ServiceUser } from "./service_user"; +import * as UserEventSourcing from "./user_eventsourcing"; +import * as UserPermissionGranted from "./user_permission_granted"; +import * as UserRecord from "./user_record"; + +interface Repository { + getTargetUser(userId: UserRecord.Id): Promise>; +} + +type eventTypeType = "user_permission_granted"; +const eventType: eventTypeType = "user_permission_granted"; + +export async function grantUserPermission( + ctx: Ctx, + issuer: ServiceUser, + userId: UserRecord.Id, + grantee: Identity, + intent: Intent, + repository: Repository, +): Promise> { + const user = await repository.getTargetUser(userId); + + if (Result.isErr(user)) { + return new NotFound(ctx, "user", userId); + } + + // Create the new event: + const permissionGranted = UserPermissionGranted.createEvent( + ctx.source, + issuer.id, + userId, + intent, + grantee, + ); + + // Check authorization (if not root): + if (issuer.id !== "root") { + const grantIntent: Intent = "user.intent.grantPermission"; + if (!UserRecord.permits(user, issuer, [grantIntent])) { + return new NotAuthorized({ ctx, userId: issuer.id, intent: grantIntent, target: user }); + } + } + + // Check that the new event is indeed valid: + const updatedUser = UserEventSourcing.newUserFromEvent(ctx, user, permissionGranted); + if (Result.isErr(updatedUser)) { + return new InvalidCommand(ctx, permissionGranted, [updatedUser]); + } + + // Only emit the event if it causes any changes to the permissions: + if (isEqual(user.permissions, updatedUser.permissions)) { + return []; + } else { + return [permissionGranted]; + } +} diff --git a/api/src/service/domain/organization/user_permission_granted.ts b/api/src/service/domain/organization/user_permission_granted.ts new file mode 100644 index 000000000..46e240096 --- /dev/null +++ b/api/src/service/domain/organization/user_permission_granted.ts @@ -0,0 +1,86 @@ +import Joi = require("joi"); +import { VError } from "verror"; + +import Intent, { userIntents } from "../../../authz/intents"; +import * as Result from "../../../result"; +import { Identity } from "./identity"; +import * as UserRecord from "./user_record"; + +type eventTypeType = "user_permission_granted"; +const eventType: eventTypeType = "user_permission_granted"; + +export interface Event { + type: eventTypeType; + source: string; + time: string; // ISO timestamp + publisher: Identity; + userId: UserRecord.Id; + permission: Intent; + grantee: Identity; +} + +export const schema = Joi.object({ + type: Joi.valid(eventType).required(), + source: Joi.string() + .allow("") + .required(), + time: Joi.date() + .iso() + .required(), + publisher: Joi.string().required(), + userId: UserRecord.idSchema.required(), + permission: Joi.valid(userIntents).required(), + grantee: Joi.string().required(), +}); + +export function createEvent( + source: string, + publisher: Identity, + userId: UserRecord.Id, + permission: Intent, + grantee: Identity, + time: string = new Date().toISOString(), +): Event { + const event = { + type: eventType, + source, + publisher, + time, + userId, + permission, + grantee, + }; + const validationResult = validate(event); + if (Result.isErr(validationResult)) { + throw new VError(validationResult, `not a valid ${eventType} event`); + } + return event; +} + +export function validate(input: any): Result.Type { + const { error, value } = Joi.validate(input, schema); + return !error ? value : error; +} + +/** + * Applies the event to the given user, or returns an error. + * + * When an error is returned (or thrown), any already applied modifications are + * discarded. + * + * This function is not expected to validate its changes; instead, the modified user + * is automatically validated when obtained using + * `user_eventsourcing.ts`:`newUserFromEvent`. + */ +export function mutate(user: UserRecord.UserRecord, event: Event): Result.Type { + if (event.type !== "user_permission_granted") { + throw new VError(`illegal event type: ${event.type}`); + } + + const eligibleIdentities = user.permissions[event.permission] || []; + if (!eligibleIdentities.includes(event.grantee)) { + eligibleIdentities.push(event.grantee); + } + + user.permissions[event.permission] = eligibleIdentities; +} diff --git a/api/src/service/domain/organization/user_permission_revoke.spec.ts b/api/src/service/domain/organization/user_permission_revoke.spec.ts new file mode 100644 index 000000000..922f89537 --- /dev/null +++ b/api/src/service/domain/organization/user_permission_revoke.spec.ts @@ -0,0 +1,128 @@ +import { assert } from "chai"; + +import { Ctx } from "../../../lib/ctx"; +import * as Result from "../../../result"; +import { NotAuthorized } from "../errors/not_authorized"; +import { ServiceUser } from "../organization/service_user"; +import { newUserFromEvent } from "./user_eventsourcing"; +import { revokeUserPermission } from "./user_permission_revoke"; +import { UserRecord } from "./user_record"; + +const ctx: Ctx = { requestId: "", source: "test" }; +const root: ServiceUser = { id: "root", groups: [] }; +const alice: ServiceUser = { id: "alice", groups: ["alice_and_bob", "alice_and_bob_and_charlie"] }; +const bob: ServiceUser = { id: "bob", groups: ["alice_and_bob", "alice_and_bob_and_charlie"] }; +const charlie: ServiceUser = { id: "charlie", groups: ["alice_and_bob_and_charlie"] }; + +const grantIntent = "user.intent.grantPermission"; +const userId = "dummy"; +const baseUser: UserRecord = { + id: userId, + createdAt: new Date().toISOString(), + organization: "dummy", + displayName: "dummy", + passwordHash: "password", + permissions: { "user.intent.revokePermission": [alice.id] }, + address: "12345", + encryptedPrivKey: "encrypted", + log: [], + additionalData: {}, +}; + +const baseRepository = { + getTargetUser: async () => baseUser, +}; + +describe("Revoking user permissions: permissions", () => { + it("Without the user.intent.revokePermission permission, a user cannot revoke user permissions", async () => { + const result = await revokeUserPermission(ctx, alice, userId, bob.id, grantIntent, { + getTargetUser: async () => + Promise.resolve({ + ...baseUser, + permissions: { "user.intent.grantPermission": [alice.id] }, + }), + }); + + // NotAuthorized error due to the missing permissions: + assert.isTrue(Result.isErr(result), "Alice is not authorized to grant this permission"); + assert.instanceOf(result, NotAuthorized, "The error is of the type 'Not Authorized'"); + }); + + it("The root user can always revoke user permissions", async () => { + const result = await revokeUserPermission(ctx, root, userId, bob.id, grantIntent, { + ...baseRepository, + }); + + assert.isTrue(Result.isOk(result)); + }); +}); + +describe("Revoking user permissions: updates", () => { + it("The permission is revoked if the user has the correct permissions", async () => { + const permissionTestUser: UserRecord = { + ...baseUser, + permissions: { + "user.intent.revokePermission": [alice.id], + "user.intent.grantPermission": [bob.id], + }, + }; + const result = await revokeUserPermission(ctx, alice, userId, bob.id, grantIntent, { + getTargetUser: async () => Promise.resolve(permissionTestUser), + }); + if (Result.isErr(result)) { + throw result; + } + + const userAfterRevokingPermission = result.reduce( + (user, event) => newUserFromEvent(ctx, user, event), + baseUser, + ); + if (Result.isErr(userAfterRevokingPermission)) { + throw userAfterRevokingPermission; + } + + assert.isTrue(Result.isOk(result), "Alice is authorized to grant this permission"); + assert.isTrue(result.length > 0, "An event is created"); + // Permission that was revoked does not exist anymore + assert.isFalse(userAfterRevokingPermission.permissions.hasOwnProperty(grantIntent)); + // Alice still has the permission to revoke permissions + assert.isTrue( + userAfterRevokingPermission.permissions["user.intent.revokePermission"]!.some( + x => x === alice.id, + ), + ); + }); + + it("A not existing permission is revoked, nothing happens", async () => { + const testIntent = "user.changePassword"; + const permissionTestUser: UserRecord = { + ...baseUser, + permissions: { + "user.intent.revokePermission": [alice.id], + }, + }; + const result = await revokeUserPermission(ctx, alice, userId, alice.id, testIntent, { + getTargetUser: async () => Promise.resolve(permissionTestUser), + }); + if (Result.isErr(result)) { + throw result; + } + + const userAfterRevokingPermission = result.reduce( + (user, event) => newUserFromEvent(ctx, user, event), + baseUser, + ); + if (Result.isErr(userAfterRevokingPermission)) { + throw userAfterRevokingPermission; + } + + assert.isTrue(Result.isOk(result), "Alice is authorized to revoke this permission"); + assert.deepEqual(result, []); + // Alice still has the permission to revoke permissions + assert.isTrue( + userAfterRevokingPermission.permissions["user.intent.revokePermission"]!.some( + x => x === alice.id, + ), + ); + }); +}); diff --git a/api/src/service/domain/organization/user_permission_revoke.ts b/api/src/service/domain/organization/user_permission_revoke.ts new file mode 100644 index 000000000..15d9e2053 --- /dev/null +++ b/api/src/service/domain/organization/user_permission_revoke.ts @@ -0,0 +1,63 @@ +import isEqual = require("lodash.isequal"); + +import Intent from "../../../authz/intents"; +import { Ctx } from "../../../lib/ctx"; +import * as Result from "../../../result"; +import { BusinessEvent } from "../business_event"; +import { InvalidCommand } from "../errors/invalid_command"; +import { NotAuthorized } from "../errors/not_authorized"; +import { NotFound } from "../errors/not_found"; +import { Identity } from "../organization/identity"; +import { ServiceUser } from "../organization/service_user"; +import * as UserEventSourcing from "./user_eventsourcing"; +import * as UserPermissionRevoked from "./user_permission_revoked"; +import * as UserRecord from "./user_record"; + +interface Repository { + getTargetUser(userId: UserRecord.Id): Promise>; +} + +export async function revokeUserPermission( + ctx: Ctx, + issuer: ServiceUser, + userId: UserRecord.Id, + revokee: Identity, + intent: Intent, + repository: Repository, +): Promise> { + const user = await repository.getTargetUser(userId); + + if (Result.isErr(user)) { + return new NotFound(ctx, "user", userId); + } + + // Create the new event: + const permissionRevoked = UserPermissionRevoked.createEvent( + ctx.source, + issuer.id, + userId, + intent, + revokee, + ); + + // Check authorization (if not root): + if (issuer.id !== "root") { + const revokeIntent = "user.intent.revokePermission"; + if (!UserRecord.permits(user, issuer, [revokeIntent])) { + return new NotAuthorized({ ctx, userId: issuer.id, intent: revokeIntent, target: user }); + } + } + + // Check that the new event is indeed valid: + const updatedUser = UserEventSourcing.newUserFromEvent(ctx, user, permissionRevoked); + if (Result.isErr(updatedUser)) { + return new InvalidCommand(ctx, permissionRevoked, [updatedUser]); + } + + // Only emit the event if it causes any changes to the permissions: + if (isEqual(user.permissions, updatedUser.permissions)) { + return []; + } else { + return [permissionRevoked]; + } +} diff --git a/api/src/service/domain/organization/user_permission_revoked.ts b/api/src/service/domain/organization/user_permission_revoked.ts new file mode 100644 index 000000000..3593ef188 --- /dev/null +++ b/api/src/service/domain/organization/user_permission_revoked.ts @@ -0,0 +1,92 @@ +import Joi = require("joi"); +import { VError } from "verror"; + +import Intent, { userIntents } from "../../../authz/intents"; +import * as Result from "../../../result"; +import { Identity } from "../organization/identity"; +import * as UserRecord from "./user_record"; + +type eventTypeType = "user_permission_revoked"; +const eventType: eventTypeType = "user_permission_revoked"; + +export interface Event { + type: eventTypeType; + source: string; + time: string; // ISO timestamp + publisher: Identity; + userId: UserRecord.Id; + permission: Intent; + revokee: Identity; +} + +export const schema = Joi.object({ + type: Joi.valid(eventType).required(), + source: Joi.string() + .allow("") + .required(), + time: Joi.date() + .iso() + .required(), + publisher: Joi.string().required(), + userId: UserRecord.idSchema.required(), + permission: Joi.valid(userIntents).required(), + revokee: Joi.string().required(), +}); + +export function createEvent( + source: string, + publisher: Identity, + userId: UserRecord.Id, + permission: Intent, + revokee: Identity, + time: string = new Date().toISOString(), +): Event { + const event = { + type: eventType, + source, + publisher, + time, + userId, + permission, + revokee, + }; + const validationResult = validate(event); + if (Result.isErr(validationResult)) { + throw new VError(validationResult, `not a valid ${eventType} event`); + } + return event; +} + +export function validate(input: any): Result.Type { + const { error, value } = Joi.validate(input, schema); + return !error ? value : error; +} + +/** + * Applies the event to the given user, or returns an error. + * + * When an error is returned (or thrown), any already applied modifications are + * discarded. + * + * This function is not expected to validate its changes; instead, the modified user + * is automatically validated when obtained using + * `user_eventsourcing.ts`:`newUserFromEvent`. + */ +export function mutate(user: UserRecord.UserRecord, event: Event): Result.Type { + if (event.type !== "user_permission_revoked") { + throw new VError(`illegal event type: ${event.type}`); + } + + const eligibleIdentities = user.permissions[event.permission]; + if (eligibleIdentities === undefined) { + // Nothing to do here.. + return; + } + + const foundIndex = eligibleIdentities.indexOf(event.revokee); + const hasPermission = foundIndex !== -1; + if (hasPermission) { + // Remove the user from the array: + eligibleIdentities.splice(foundIndex, 1); + } +} diff --git a/api/src/service/project_permission_grant.ts b/api/src/service/project_permission_grant.ts index 84b401044..1f45944c1 100644 --- a/api/src/service/project_permission_grant.ts +++ b/api/src/service/project_permission_grant.ts @@ -22,8 +22,8 @@ export async function grantProjectPermission( ): Promise { const result = await Cache.withCache(conn, ctx, async cache => ProjectPermissionGrant.grantProjectPermission(ctx, serviceUser, projectId, grantee, intent, { - getProject: async projectId => { - return cache.getProject(projectId); + getProject: async id => { + return cache.getProject(id); }, }), ); diff --git a/api/src/service/project_permission_revoke.ts b/api/src/service/project_permission_revoke.ts index 923936217..395a27fe0 100644 --- a/api/src/service/project_permission_revoke.ts +++ b/api/src/service/project_permission_revoke.ts @@ -21,8 +21,8 @@ export async function revokeProjectPermission( ): Promise { const result = await Cache.withCache(conn, ctx, async cache => ProjectPermissionRevoke.revokeProjectPermission(ctx, serviceUser, projectId, revokee, intent, { - getProject: async projectId => { - return cache.getProject(projectId); + getProject: async id => { + return cache.getProject(id); }, }), ); diff --git a/api/src/service/store.ts b/api/src/service/store.ts index ff782871e..3d7ad643a 100644 --- a/api/src/service/store.ts +++ b/api/src/service/store.ts @@ -65,6 +65,14 @@ export async function store(conn: ConnToken, ctx: Ctx, event: BusinessEvent): Pr event, }); + case "user_permission_granted": + case "user_permission_revoked": + return writeTo(conn, ctx, { + stream: "users", + keys: [event.userId], + event, + }); + case "workflowitems_reordered": return writeTo(conn, ctx, { stream: event.projectId, diff --git a/api/src/service/user_permission_grant.ts b/api/src/service/user_permission_grant.ts new file mode 100644 index 000000000..22aa2fece --- /dev/null +++ b/api/src/service/user_permission_grant.ts @@ -0,0 +1,38 @@ +import Intent from "../authz/intents"; +import { Ctx } from "../lib/ctx"; +import * as Result from "../result"; +import * as Cache from "./cache2"; +import { ConnToken } from "./conn"; +import { Identity } from "./domain/organization/identity"; +import { ServiceUser } from "./domain/organization/service_user"; +import * as UserPermissionGrant from "./domain/organization/user_permission_grant"; +import * as Project from "./domain/workflow/project"; +import { store } from "./store"; +import * as UserQuery from "./user_query"; + +export { RequestData } from "./domain/workflow/project_create"; + +export async function grantUserPermission( + conn: ConnToken, + ctx: Ctx, + serviceUser: ServiceUser, + userId: Project.Id, + grantee: Identity, + intent: Intent, +): Promise { + const result = await UserPermissionGrant.grantUserPermission( + ctx, + serviceUser, + userId, + grantee, + intent, + { + getTargetUser: id => UserQuery.getUser(conn, ctx, serviceUser, id), + }, + ); + if (Result.isErr(result)) return Promise.reject(result); + + for (const event of result) { + await store(conn, ctx, event); + } +} diff --git a/api/src/service/user_permission_revoke.ts b/api/src/service/user_permission_revoke.ts new file mode 100644 index 000000000..32605bb8d --- /dev/null +++ b/api/src/service/user_permission_revoke.ts @@ -0,0 +1,33 @@ +import Intent from "../authz/intents"; +import { Ctx } from "../lib/ctx"; +import * as Result from "../result"; +import * as Cache from "./cache2"; +import { ConnToken } from "./conn"; +import { Identity } from "./domain/organization/identity"; +import { ServiceUser } from "./domain/organization/service_user"; +import * as UserPermissionRevoke from "./domain/organization/user_permission_revoke"; +import * as UserRecord from "./domain/organization/user_record"; +import { store } from "./store"; +import * as UserQuery from "./user_query"; + +export { RequestData } from "./domain/workflow/project_create"; + +export async function revokeUserPermission( + conn: ConnToken, + ctx: Ctx, + serviceUser: ServiceUser, + userId: UserRecord.Id, + revokee: Identity, + intent: Intent, +): Promise { + const result = await Cache.withCache(conn, ctx, async cache => + UserPermissionRevoke.revokeUserPermission(ctx, serviceUser, userId, revokee, intent, { + getTargetUser: id => UserQuery.getUser(conn, ctx, serviceUser, id), + }), + ); + if (Result.isErr(result)) return Promise.reject(result); + + for (const event of result) { + await store(conn, ctx, event); + } +} diff --git a/api/src/service/user_permissions_list.ts b/api/src/service/user_permissions_list.ts new file mode 100644 index 000000000..9616a22c3 --- /dev/null +++ b/api/src/service/user_permissions_list.ts @@ -0,0 +1,28 @@ +import { Ctx } from "../lib/ctx"; +import * as Result from "../result"; +import * as Cache from "./cache2"; +import { ConnToken } from "./conn"; +import { ServiceUser } from "./domain/organization/service_user"; +import * as UserGet from "./domain/organization/user_get"; +import * as UserRecord from "./domain/organization/user_record"; +import { Permissions } from "./domain/permissions"; + +export async function getUserPermissions( + conn: ConnToken, + ctx: Ctx, + serviceUser: ServiceUser, + userId: UserRecord.Id, +): Promise> { + const userResult = await Cache.withCache(conn, ctx, async cache => + UserGet.getOneUser(ctx, serviceUser, userId, { + getUserEvents: async () => cache.getUserEvents(userId), + }), + ); + + if (Result.isErr(userResult)) { + userResult.message = `could not fetch user permissions: ${userResult.message}`; + return userResult; + } + + return userResult.permissions; +} diff --git a/api/src/user_permission_grant.ts b/api/src/user_permission_grant.ts new file mode 100644 index 000000000..cbb4cfcad --- /dev/null +++ b/api/src/user_permission_grant.ts @@ -0,0 +1,142 @@ +import { FastifyInstance } from "fastify"; +import Joi = require("joi"); +import { VError } from "verror"; + +import Intent, { userIntents } from "./authz/intents"; +import { toHttpError } from "./http_errors"; +import * as NotAuthenticated from "./http_errors/not_authenticated"; +import { AuthenticatedRequest } from "./httpd/lib"; +import { Ctx } from "./lib/ctx"; +import * as Result from "./result"; +import { Identity } from "./service/domain/organization/identity"; +import { ServiceUser } from "./service/domain/organization/service_user"; +import * as UserRecord from "./service/domain/organization/user_record"; + +interface RequestBodyV1 { + apiVersion: "1.0"; + data: { + userId: UserRecord.Id; + identity: Identity; + intent: Intent; + }; +} + +const requestBodyV1Schema = Joi.object({ + apiVersion: Joi.valid("1.0").required(), + data: Joi.object({ + userId: UserRecord.idSchema.required(), + identity: Joi.string().required(), + intent: Joi.valid(userIntents).required(), + }).required(), +}); + +type RequestBody = RequestBodyV1; +const requestBodySchema = Joi.alternatives([requestBodyV1Schema]); + +function validateRequestBody(body: any): Result.Type { + const { error, value } = Joi.validate(body, requestBodySchema); + return !error ? value : error; +} + +function mkSwaggerSchema(server: FastifyInstance) { + return { + beforeHandler: [(server as any).authenticate], + schema: { + description: + "Grant a permission to a user. After this call has returned, the " + + "user will be allowed to execute the given intent.", + tags: ["user"], + summary: "Grant a permission to a user or group", + security: [ + { + bearerToken: [], + }, + ], + body: { + type: "object", + required: ["apiVersion", "data"], + properties: { + apiVersion: { type: "string", example: "1.0" }, + data: { + type: "object", + required: ["identity", "intent", "userId"], + properties: { + identity: { type: "string", example: "aSmith" }, + intent: { + type: "string", + enum: userIntents, + example: "user.intent.listPermissions", + }, + userId: { type: "string", example: "aSmith" }, + }, + }, + }, + }, + response: { + 200: { + description: "successful response", + type: "object", + properties: { + apiVersion: { type: "string", example: "1.0" }, + data: { + type: "object", + }, + }, + }, + 401: NotAuthenticated.schema, + }, + }, + }; +} + +interface Service { + grantUserPermission( + ctx: Ctx, + granter: ServiceUser, + userId: UserRecord.Id, + grantee: Identity, + intent: Intent, + ): Promise; +} + +export function addHttpHandler(server: FastifyInstance, urlPrefix: string, service: Service) { + server.post( + `${urlPrefix}/user.intent.grantPermission`, + mkSwaggerSchema(server), + (request, reply) => { + const ctx: Ctx = { requestId: request.id, source: "http" }; + + const granter: ServiceUser = { + id: (request as AuthenticatedRequest).user.userId, + groups: (request as AuthenticatedRequest).user.groups, + }; + + const bodyResult = validateRequestBody(request.body); + + if (Result.isErr(bodyResult)) { + const { code, body } = toHttpError( + new VError(bodyResult, "failed to grant user permission"), + ); + reply.status(code).send(body); + return; + } + + const { userId, identity: grantee, intent } = bodyResult.data; + + service + .grantUserPermission(ctx, granter, userId, grantee, intent) + .then(() => { + const code = 200; + const body = { + apiVersion: "1.0", + data: {}, + }; + reply.status(code).send(body); + }) + .catch(err => { + const { code, body } = toHttpError(err); + reply.status(code).send(body); + }); + }, + ); +} diff --git a/api/src/user_permission_revoke.ts b/api/src/user_permission_revoke.ts new file mode 100644 index 000000000..d7f0fecc9 --- /dev/null +++ b/api/src/user_permission_revoke.ts @@ -0,0 +1,142 @@ +import { FastifyInstance } from "fastify"; +import Joi = require("joi"); +import { VError } from "verror"; + +import Intent, { userIntents } from "./authz/intents"; +import { toHttpError } from "./http_errors"; +import * as NotAuthenticated from "./http_errors/not_authenticated"; +import { AuthenticatedRequest } from "./httpd/lib"; +import { Ctx } from "./lib/ctx"; +import * as Result from "./result"; +import { Identity } from "./service/domain/organization/identity"; +import { ServiceUser } from "./service/domain/organization/service_user"; +import * as UserRecord from "./service/domain/organization/user_record"; + +interface RequestBodyV1 { + apiVersion: "1.0"; + data: { + userId: UserRecord.Id; + identity: Identity; + intent: Intent; + }; +} + +const requestBodyV1Schema = Joi.object({ + apiVersion: Joi.valid("1.0").required(), + data: Joi.object({ + userId: UserRecord.idSchema.required(), + identity: Joi.string().required(), + intent: Joi.valid(userIntents).required(), + }).required(), +}); + +type RequestBody = RequestBodyV1; +const requestBodySchema = Joi.alternatives([requestBodyV1Schema]); + +function validateRequestBody(body: any): Result.Type { + const { error, value } = Joi.validate(body, requestBodySchema); + return !error ? value : error; +} + +function mkSwaggerSchema(server: FastifyInstance) { + return { + beforeHandler: [(server as any).authenticate], + schema: { + description: + "Revoke a permission from a user. After this call has returned, the " + + "user will no longer be able to execute the given intent.", + tags: ["user"], + summary: "Revoke a permission from a user or group", + security: [ + { + bearerToken: [], + }, + ], + body: { + type: "object", + required: ["apiVersion", "data"], + properties: { + apiVersion: { type: "string", example: "1.0" }, + data: { + type: "object", + required: ["identity", "intent", "userId"], + properties: { + identity: { type: "string", example: "aSmith" }, + intent: { + type: "string", + enum: userIntents, + example: "user.intent.listPermissions", + }, + userId: { type: "string", example: "aSmith" }, + }, + }, + }, + }, + response: { + 200: { + description: "successful response", + type: "object", + properties: { + apiVersion: { type: "string", example: "1.0" }, + data: { + type: "object", + }, + }, + }, + 401: NotAuthenticated.schema, + }, + }, + }; +} + +interface Service { + revokeUserPermission( + ctx: Ctx, + revoker: ServiceUser, + userId: UserRecord.Id, + revokee: Identity, + intent: Intent, + ): Promise; +} + +export function addHttpHandler(server: FastifyInstance, urlPrefix: string, service: Service) { + server.post( + `${urlPrefix}/user.intent.revokePermission`, + mkSwaggerSchema(server), + (request, reply) => { + const ctx: Ctx = { requestId: request.id, source: "http" }; + + const revoker: ServiceUser = { + id: (request as AuthenticatedRequest).user.userId, + groups: (request as AuthenticatedRequest).user.groups, + }; + + const bodyResult = validateRequestBody(request.body); + + if (Result.isErr(bodyResult)) { + const { code, body } = toHttpError( + new VError(bodyResult, "failed to revoke user permission"), + ); + reply.status(code).send(body); + return; + } + + const { userId, identity: revokee, intent } = bodyResult.data; + + service + .revokeUserPermission(ctx, revoker, userId, revokee, intent) + .then(() => { + const code = 200; + const body = { + apiVersion: "1.0", + data: {}, + }; + reply.status(code).send(body); + }) + .catch(err => { + const { code, body } = toHttpError(err); + reply.status(code).send(body); + }); + }, + ); +} diff --git a/api/src/user_permissions_list.ts b/api/src/user_permissions_list.ts new file mode 100644 index 000000000..c20e9ba5e --- /dev/null +++ b/api/src/user_permissions_list.ts @@ -0,0 +1,105 @@ +import { FastifyInstance } from "fastify"; + +import { toHttpError } from "./http_errors"; +import * as NotAuthenticated from "./http_errors/not_authenticated"; +import { AuthenticatedRequest } from "./httpd/lib"; +import { Ctx } from "./lib/ctx"; +import { isNonemptyString } from "./lib/validation"; +import * as Result from "./result"; +import { ServiceUser } from "./service/domain/organization/service_user"; +import { Permissions } from "./service/domain/permissions"; + +function mkSwaggerSchema(server: FastifyInstance) { + return { + beforeHandler: [(server as any).authenticate], + schema: { + description: "See the permissions for a given user.", + tags: ["user"], + summary: "List all permissions", + querystring: { + type: "object", + properties: { + userId: { + type: "string", + }, + }, + }, + security: [ + { + bearerToken: [], + }, + ], + response: { + 200: { + description: "successful response", + type: "object", + properties: { + apiVersion: { type: "string", example: "1.0" }, + data: { + type: "object", + additionalProperties: true, + example: { + "user.changePassword": ["aSmith", "jDoe"], + }, + }, + }, + }, + 401: NotAuthenticated.schema, + }, + }, + }; +} + +interface Service { + getUserPermissions( + ctx: Ctx, + user: ServiceUser, + userId: string, + ): Promise>; +} + +export function addHttpHandler(server: FastifyInstance, urlPrefix: string, service: Service) { + server.get( + `${urlPrefix}/user.intent.listPermissions`, + mkSwaggerSchema(server), + async (request, reply) => { + const ctx: Ctx = { requestId: request.id, source: "http" }; + + const user: ServiceUser = { + id: (request as AuthenticatedRequest).user.userId, + groups: (request as AuthenticatedRequest).user.groups, + }; + + const userId = request.query.userId; + if (!isNonemptyString(userId)) { + reply.status(404).send({ + apiVersion: "1.0", + error: { + code: 404, + message: "required query parameter `userId` not present (must be non-empty string)", + }, + }); + return; + } + + try { + const userPermissions = await service.getUserPermissions(ctx, user, userId); + + if (Result.isErr(userPermissions)) { + userPermissions.message = `could not list user permissions: ${userPermissions.message}`; + throw userPermissions; + } + + const code = 200; + const body = { + apiVersion: "1.0", + data: userPermissions, + }; + reply.status(code).send(body); + } catch (err) { + const { code, body } = toHttpError(err); + reply.status(code).send(body); + } + }, + ); +} From d7ef6e5872d2e8dad6322092405c7f0d0f480721 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20H=C3=B6ld?= Date: Thu, 6 Jun 2019 12:01:56 +0200 Subject: [PATCH 20/21] ui: Reset state for workflow item history when changing tabs When a user views the workflow item details and clicks on history and then back to another tab, the history state is now reset, so the calculation of offset and limit are correct. Add e2e test that makes sure this does not happen again. Refactor e2e tests, so that 'data-test' is used instead of 'id'. Closes #317 Rework based on review - Moved WorkflowitemHistoryTab into Workflows - Rewrote WorkflowitemHistory to class based component - Removed effect hook for resetting history state and moved it to ComponentWillUnmount - Fixed e2e-tests - Added LOGOUT action to WorkflowitemHistory reducer --- CHANGELOG.md | 1 + .../cypress/integration/documents_spec.js | 2 +- .../integration/project_history_spec.js | 14 ++--- .../integration/subproject_history_spec.js | 14 ++--- .../integration/workflowitem_history_spec.js | 39 ++++++++++--- e2e-test/cypress/plugins/index.js | 50 +++++++++-------- frontend/src/pages/Common/HistoryList.js | 2 +- .../WorkflowitemDetails/WorkflowHistoryTab.js | 48 ---------------- .../src/pages/Workflows/WorkflowDetails.js | 8 +-- .../WorkflowHistoryTab.js | 56 +++++++++++++++++++ .../WorkflowitemHistoryTab}/actions.js | 8 +++ .../WorkflowitemHistoryTab}/reducer.js | 13 +++-- frontend/src/reducers.js | 2 +- frontend/src/sagas.js | 4 +- 14 files changed, 154 insertions(+), 107 deletions(-) delete mode 100644 frontend/src/pages/WorkflowitemDetails/WorkflowHistoryTab.js create mode 100644 frontend/src/pages/Workflows/WorkflowitemHistoryTab/WorkflowHistoryTab.js rename frontend/src/pages/{WorkflowitemDetails => Workflows/WorkflowitemHistoryTab}/actions.js (79%) rename frontend/src/pages/{WorkflowitemDetails => Workflows/WorkflowitemHistoryTab}/reducer.js (74%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53bf01945..4a0b4a614 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Prevent assignee selection from overflowing [#299](https://github.com/openkfw/TruBudget/issues/299) - Display correct name in notifications [#292](https://github.com/openkfw/TruBudget/issues/292) - Workflowitem amount is only displayed if amount and exchange rate are available [#297](https://github.com/openkfw/TruBudget/issues/297) +- User is not logged out when viewing a workflow item's history [#317](https://github.com/openkfw/TruBudget/issues/317) diff --git a/e2e-test/cypress/integration/documents_spec.js b/e2e-test/cypress/integration/documents_spec.js index 1ada5602d..b0c16658a 100644 --- a/e2e-test/cypress/integration/documents_spec.js +++ b/e2e-test/cypress/integration/documents_spec.js @@ -51,7 +51,7 @@ describe("Attaching a document to a workflowitem.", function() { cy.get(".workflowitem-info-button").click(); // go to the documents tab: - cy.get("#workflowitem-documents-tab").click(); + cy.get("[data-test=workflowitem-documents-tab]").click(); // upload the same file, for validation: cy.fixture(fileName).then(fileContent => { diff --git a/e2e-test/cypress/integration/project_history_spec.js b/e2e-test/cypress/integration/project_history_spec.js index fa22f8d61..6b0aa493e 100644 --- a/e2e-test/cypress/integration/project_history_spec.js +++ b/e2e-test/cypress/integration/project_history_spec.js @@ -18,15 +18,15 @@ describe("Project's history", function() { cy.get("#project-history-button").click(); // Count history items => should be one - cy.get("#history-list li.history-item") + cy.get("[data-test=history-list] li.history-item") .first() .should("be.visible"); - cy.get("#history-list") + cy.get("[data-test=history-list]") .find("li.history-item") .should("have.length", 1); // Make sure it's a creation event - cy.get("#history-list") + cy.get("[data-test=history-list]") .find("li.history-item") .first() .should("contain", "created project"); @@ -43,21 +43,21 @@ describe("Project's history", function() { cy.get("#project-history-button").click(); // Count history items => should be two - cy.get("#history-list li.history-item") + cy.get("[data-test=history-list] li.history-item") .first() .should("be.visible"); - cy.get("#history-list") + cy.get("[data-test=history-list]") .find("li.history-item") .should("have.length", 2); // Make sure the oldest entry is the create event - cy.get("#history-list") + cy.get("[data-test=history-list]") .find("li.history-item") .last() .should("contain", "created project"); // Make sure the newest entry is the assign event - cy.get("#history-list") + cy.get("[data-test=history-list]") .find("li.history-item") .first() .should("contain", "assigned project"); diff --git a/e2e-test/cypress/integration/subproject_history_spec.js b/e2e-test/cypress/integration/subproject_history_spec.js index 8a9ba4679..1099dad30 100644 --- a/e2e-test/cypress/integration/subproject_history_spec.js +++ b/e2e-test/cypress/integration/subproject_history_spec.js @@ -25,15 +25,15 @@ describe("Subproject's history", function() { cy.get("#subproject-history-button").click(); // Count history items => should be one - cy.get("#history-list li.history-item") + cy.get("[data-test=history-list] li.history-item") .first() .should("be.visible"); - cy.get("#history-list") + cy.get("[data-test=history-list]") .find("li.history-item") .should("have.length", 1); // Make sure it's a creation event - cy.get("#history-list") + cy.get("[data-test=history-list]") .find("li.history-item") .first() .should("contain", "created subproject"); @@ -52,21 +52,21 @@ describe("Subproject's history", function() { cy.get("#subproject-history-button").click(); // Count history items => should be two - cy.get("#history-list li.history-item") + cy.get("[data-test=history-list] li.history-item") .first() .should("be.visible"); - cy.get("#history-list") + cy.get("[data-test=history-list]") .find("li.history-item") .should("have.length", 2); // Make sure the oldest entry is the create event - cy.get("#history-list") + cy.get("[data-test=history-list]") .find("li.history-item") .last() .should("contain", "created subproject"); // Make sure the newest entry is the assign event - cy.get("#history-list") + cy.get("[data-test=history-list]") .find("li.history-item") .first() .should("contain", "assigned subproject"); diff --git a/e2e-test/cypress/integration/workflowitem_history_spec.js b/e2e-test/cypress/integration/workflowitem_history_spec.js index 60211ad01..06f164e11 100644 --- a/e2e-test/cypress/integration/workflowitem_history_spec.js +++ b/e2e-test/cypress/integration/workflowitem_history_spec.js @@ -26,18 +26,18 @@ describe("Workflowitem's history", function() { // opens info dialog window.. - cy.get("#workflowitem-history-tab").click(); + cy.get("[data-test=workflowitem-history-tab]").click(); // Count history items => should be one - cy.get("#history-list li.history-item") + cy.get("[data-test=history-list] li.history-item") .first() .should("be.visible"); - cy.get("#history-list") + cy.get("[data-test=history-list]") .find("li.history-item") .should("have.length", 1); // Make sure it's a creation event - cy.get("#history-list") + cy.get("[data-test=history-list]") .find("li.history-item") .first() .should("contain", "created workflowitem"); @@ -55,26 +55,47 @@ describe("Workflowitem's history", function() { // opens info dialog window.. - cy.get("#workflowitem-history-tab").click(); + cy.get("[data-test=workflowitem-history-tab]").click(); // Count history items => should be two - cy.get("#history-list li.history-item") + cy.get("[data-test=history-list] li.history-item") .first() .should("be.visible"); - cy.get("#history-list") + cy.get("[data-test=history-list]") .find("li.history-item") .should("have.length", 2); // Make sure the oldest entry is the create event - cy.get("#history-list") + cy.get("[data-test=history-list]") .find("li.history-item") .last() .should("contain", "created workflowitem"); // Make sure the newest entry is the assign event - cy.get("#history-list") + cy.get("[data-test=history-list]") .find("li.history-item") .first() .should("contain", "assigned workflowitem"); }); + + it("When changing the tab, the history is fetched correctly", function() { + cy.get(".workflowitem-info-button").click(); + + // opens info dialog window.. + + cy.get("[data-test=workflowitem-history-tab]").click(); + + // Count history items => should be one + cy.get("[data-test=history-list] li.history-item") + .first() + .should("be.visible"); + + cy.get("[data-test=workflowitem-documents-tab]").click(); + cy.get("[data-test=workflowitem-history-tab]").click(); + + // Items should be visible and user should not be logged out + cy.get("[data-test=history-list] li.history-item") + .first() + .should("be.visible"); + }); }); diff --git a/e2e-test/cypress/plugins/index.js b/e2e-test/cypress/plugins/index.js index 54277db8b..44d050c60 100644 --- a/e2e-test/cypress/plugins/index.js +++ b/e2e-test/cypress/plugins/index.js @@ -14,31 +14,37 @@ const axios = require("axios"); function reportsReadiness(baseUrl) { - return axios.get(`${baseUrl}/api/readiness`).then(() => { - console.log("API reports readiness!"); - return true; - }).catch(err => { - console.log(`API is not ready yet: ${err}`); - return false; - }); + return axios + .get(`${baseUrl}/api/readiness`) + .then(() => { + console.log("API reports readiness!"); + return true; + }) + .catch(err => { + console.log(`API is not ready yet: ${err}`); + return false; + }); } function hasLoginReady(baseUrl) { - return axios.post(`${baseUrl}/api/user.authenticate`, { - apiVersion: "1.0", - data: { - user: { - id: "mstein", - password: "test" + return axios + .post(`${baseUrl}/api/user.authenticate`, { + apiVersion: "1.0", + data: { + user: { + id: "mstein", + password: "test" + } } - } - }).then(() => { - console.log("Login successful!"); - return true; - }).catch(err => { - console.log(`Authentication failed - likely provisioning is still ongoing; err: ${err}`); - return false; - }); + }) + .then(() => { + console.log("Login successful!"); + return true; + }) + .catch(err => { + console.log(`Authentication failed - likely provisioning is still ongoing; err: ${err}`); + return false; + }); } const sleep = timeout => new Promise(resolve => setTimeout(resolve, timeout)); @@ -63,6 +69,6 @@ async function awaitApiReady(baseUrl) { module.exports = (on, _config) => { on("task", { - awaitApiReady: awaitApiReady, + awaitApiReady: awaitApiReady }); }; diff --git a/frontend/src/pages/Common/HistoryList.js b/frontend/src/pages/Common/HistoryList.js index 7ba2c5702..6ac864b5b 100644 --- a/frontend/src/pages/Common/HistoryList.js +++ b/frontend/src/pages/Common/HistoryList.js @@ -39,7 +39,7 @@ export default function HistoryList({ events, nEventsTotal, hasMore, isLoading, return ( {strings.common.history}} style={styles.list} > diff --git a/frontend/src/pages/WorkflowitemDetails/WorkflowHistoryTab.js b/frontend/src/pages/WorkflowitemDetails/WorkflowHistoryTab.js deleted file mode 100644 index fc43e54ae..000000000 --- a/frontend/src/pages/WorkflowitemDetails/WorkflowHistoryTab.js +++ /dev/null @@ -1,48 +0,0 @@ -import React from "react"; -import { connect } from "react-redux"; - -import { toJS } from "../../helper"; -import ScrollingHistory from "../Common/History/ScrollingHistory"; -import { fetchNextWorkflowitemHistoryPage } from "../WorkflowitemDetails/actions"; - -function WorkflowitemHistoryTab({ - projectId, - subprojectId, - workflowitemId, - events, - nEventsTotal, - isLoading, - getUserDisplayname, - fetchNextWorkflowitemHistoryPage, - currentHistoryPage, - lastHistoryPage -}) { - return ( - fetchNextWorkflowitemHistoryPage(projectId, subprojectId, workflowitemId)} - initialLoad={true} - /> - ); -} - -function mapStateToProps(state) { - return { - events: state.getIn(["workflowitemDetails", "events"]), - nEventsTotal: state.getIn(["workflowitemDetails", "totalHistoryItemCount"]), - isLoading: state.getIn(["workflowitemDetails", "isHistoryLoading"]), - currentHistoryPage: state.getIn(["workflowitemDetails", "currentHistoryPage"]), - lastHistoryPage: state.getIn(["workflowitemDetails", "lastHistoryPage"]), - getUserDisplayname: uid => state.getIn(["login", "userDisplayNameMap", uid]) || "Somebody" - }; -} - -const mapDispatchToProps = { - fetchNextWorkflowitemHistoryPage -}; - -export default connect(mapStateToProps, mapDispatchToProps)(toJS(WorkflowitemHistoryTab)); diff --git a/frontend/src/pages/Workflows/WorkflowDetails.js b/frontend/src/pages/Workflows/WorkflowDetails.js index b50147990..a45f2e6e9 100644 --- a/frontend/src/pages/Workflows/WorkflowDetails.js +++ b/frontend/src/pages/Workflows/WorkflowDetails.js @@ -17,7 +17,7 @@ import React, { useEffect, useState } from "react"; import { statusIconMapping, statusMapping, toAmountString } from "../../helper"; import strings from "../../localizeStrings"; import DocumentOverviewContainer from "../Documents/DocumentOverviewContainer"; -import WorkflowitemHistoryTab from "../WorkflowitemDetails/WorkflowHistoryTab"; +import WorkflowitemHistoryTab from "./WorkflowitemHistoryTab/WorkflowHistoryTab"; const styles = { textfield: { @@ -169,9 +169,9 @@ function WorkflowDetails({ {strings.workflow.workflowitem_details} setTabIndex(index)}> - - - + + + {content} diff --git a/frontend/src/pages/Workflows/WorkflowitemHistoryTab/WorkflowHistoryTab.js b/frontend/src/pages/Workflows/WorkflowitemHistoryTab/WorkflowHistoryTab.js new file mode 100644 index 000000000..8dc827878 --- /dev/null +++ b/frontend/src/pages/Workflows/WorkflowitemHistoryTab/WorkflowHistoryTab.js @@ -0,0 +1,56 @@ +import React from "react"; +import { connect } from "react-redux"; + +import { toJS } from "../../../helper"; +import ScrollingHistory from "../../Common/History/ScrollingHistory"; +import { fetchNextWorkflowitemHistoryPage, resetWorkflowitemHistory } from "./actions"; + +class WorkflowitemHistoryTab extends React.Component { + componentWillUnmount() { + this.props.resetWorkflowitemHistory(); + } + + render() { + const { + nEventsTotal, + historyItems, + fetchNextWorkflowitemHistoryPage, + currentHistoryPage, + lastHistoryPage, + projectId, + subprojectId, + workflowitemId, + isLoading, + getUserDisplayname + } = this.props; + return ( + fetchNextWorkflowitemHistoryPage(projectId, subprojectId, workflowitemId)} + initialLoad={true} + /> + ); + } +} + +function mapStateToProps(state) { + return { + historyItems: state.getIn(["workflowitemDetails", "historyItems"]), + nEventsTotal: state.getIn(["workflowitemDetails", "totalHistoryItemCount"]), + isLoading: state.getIn(["workflowitemDetails", "isHistoryLoading"]), + currentHistoryPage: state.getIn(["workflowitemDetails", "currentHistoryPage"]), + lastHistoryPage: state.getIn(["workflowitemDetails", "lastHistoryPage"]), + getUserDisplayname: uid => state.getIn(["login", "userDisplayNameMap", uid]) || "Somebody" + }; +} + +const mapDispatchToProps = { + fetchNextWorkflowitemHistoryPage, + resetWorkflowitemHistory +}; + +export default connect(mapStateToProps, mapDispatchToProps)(toJS(WorkflowitemHistoryTab)); diff --git a/frontend/src/pages/WorkflowitemDetails/actions.js b/frontend/src/pages/Workflows/WorkflowitemHistoryTab/actions.js similarity index 79% rename from frontend/src/pages/WorkflowitemDetails/actions.js rename to frontend/src/pages/Workflows/WorkflowitemHistoryTab/actions.js index 217690f1c..3be61fa1e 100644 --- a/frontend/src/pages/WorkflowitemDetails/actions.js +++ b/frontend/src/pages/Workflows/WorkflowitemHistoryTab/actions.js @@ -2,6 +2,8 @@ export const SET_TOTAL_WORKFLOWITEM_HISTORY_ITEM_COUNT = "SET_TOTAL_WORKFLOWITEM export const FETCH_NEXT_WORKFLOWITEM_HISTORY_PAGE = "FETCH_NEXT_WORKFLOWITEM_HISTORY_PAGE"; export const FETCH_NEXT_WORKFLOWITEM_HISTORY_PAGE_SUCCESS = "FETCH_NEXT_WORKFLOWITEM_HISTORY_PAGE_SUCCESS"; +export const RESET_WORKFLOWITEM_HISTORY = "RESET_WORKFLOWITEM_HISTORY"; + export function setTotalHistoryItemCount(count) { return { type: SET_TOTAL_WORKFLOWITEM_HISTORY_ITEM_COUNT, @@ -18,3 +20,9 @@ export function fetchNextWorkflowitemHistoryPage(projectId, subprojectId, workfl showLoading }; } + +export function resetWorkflowitemHistory() { + return { + type: RESET_WORKFLOWITEM_HISTORY + }; +} diff --git a/frontend/src/pages/WorkflowitemDetails/reducer.js b/frontend/src/pages/Workflows/WorkflowitemHistoryTab/reducer.js similarity index 74% rename from frontend/src/pages/WorkflowitemDetails/reducer.js rename to frontend/src/pages/Workflows/WorkflowitemHistoryTab/reducer.js index 2ce6bb6c3..cb49243bd 100644 --- a/frontend/src/pages/WorkflowitemDetails/reducer.js +++ b/frontend/src/pages/Workflows/WorkflowitemHistoryTab/reducer.js @@ -3,14 +3,16 @@ import { fromJS } from "immutable"; import { SET_TOTAL_WORKFLOWITEM_HISTORY_ITEM_COUNT, FETCH_NEXT_WORKFLOWITEM_HISTORY_PAGE, - FETCH_NEXT_WORKFLOWITEM_HISTORY_PAGE_SUCCESS + FETCH_NEXT_WORKFLOWITEM_HISTORY_PAGE_SUCCESS, + RESET_WORKFLOWITEM_HISTORY } from "./actions"; -import { CLOSE_WORKFLOWITEM_DETAILS } from "../Workflows/actions"; +import { CLOSE_WORKFLOWITEM_DETAILS } from "../actions"; +import { LOGOUT } from "../../Login/actions"; const historyPageSize = 30; const initialState = fromJS({ - events: [], + historyItems: [], totalHistoryItemCount: 0, historyPageSize: historyPageSize, currentHistoryPage: 0, @@ -31,12 +33,13 @@ export default function reducer(state = initialState, action) { case FETCH_NEXT_WORKFLOWITEM_HISTORY_PAGE_SUCCESS: return state.merge({ - events: state.get("events").concat(fromJS(action.events).reverse()), + historyItems: state.get("historyItems").concat(fromJS(action.events).reverse()), currentHistoryPage: action.currentHistoryPage, isHistoryLoading: false }); - + case RESET_WORKFLOWITEM_HISTORY: case CLOSE_WORKFLOWITEM_DETAILS: + case LOGOUT: return initialState; default: diff --git a/frontend/src/reducers.js b/frontend/src/reducers.js index 85ad1739f..ff4307abf 100644 --- a/frontend/src/reducers.js +++ b/frontend/src/reducers.js @@ -13,7 +13,7 @@ import subProjectReducer from "./pages/SubProjects/reducer"; import dashboardReducer from "./pages/Dashboard/reducer"; import notificationsReducer from "./pages/Notifications/reducer"; import workflowReducer from "./pages/Workflows/reducer"; -import workflowitemDetailsReducer from "./pages/WorkflowitemDetails/reducer"; +import workflowitemDetailsReducer from "./pages/Workflows/WorkflowitemHistoryTab/reducer"; import loginReducer from "./pages/Login/reducer"; import documentsReducer from "./pages/Documents/reducer"; import loadingReducer from "./pages/Loading/reducer"; diff --git a/frontend/src/sagas.js b/frontend/src/sagas.js index b60331d8b..72cf129a3 100644 --- a/frontend/src/sagas.js +++ b/frontend/src/sagas.js @@ -95,7 +95,7 @@ import { SET_TOTAL_WORKFLOWITEM_HISTORY_ITEM_COUNT, FETCH_NEXT_WORKFLOWITEM_HISTORY_PAGE, FETCH_NEXT_WORKFLOWITEM_HISTORY_PAGE_SUCCESS -} from "./pages/WorkflowitemDetails/actions"; +} from "./pages/Workflows/WorkflowitemHistoryTab/actions"; import { LOGIN, @@ -941,7 +941,7 @@ export function* fetchNextWorkflowitemHistoryPageSaga({ projectId, subprojectId, const remainingItems = totalHistoryItemCount - currentHistoryPage * historyPageSize; // If the remaining items are 0, it means that the total number of history items // is a multiple of the page size and we need to fetch a whole page - const limit = isLastPage && remainingItems !== 0 ? remainingItems : historyPageSize; + const limit = isLastPage && remainingItems > 0 ? remainingItems : historyPageSize; const { historyItemsCount, events } = yield callApi( api.viewWorkflowitemHistory, From 362666b55dfcc5fe43ee320beaf73cf75bc58a85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20H=C3=B6ld?= Date: Wed, 12 Jun 2019 10:02:31 +0200 Subject: [PATCH 21/21] Bump to version 1.1.0 --- CHANGELOG.md | 23 ++++++++++++++++------- api/package-lock.json | 2 +- api/package.json | 2 +- blockchain/package-lock.json | 2 +- blockchain/package.json | 2 +- e2e-test/package-lock.json | 2 +- e2e-test/package.json | 2 +- excel-export/package-lock.json | 2 +- excel-export/package.json | 2 +- frontend/package-lock.json | 2 +- frontend/package.json | 2 +- provisioning/package-lock.json | 2 +- provisioning/package.json | 2 +- 13 files changed, 28 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f939d113f..31c936a12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] + + + + + + + + + + + + +## [1.1.0] - 2019-06-12 + ### Added - New API endpoint to change a user's password [#79](https://github.com/openkfw/TruBudget/issues/79) @@ -19,10 +33,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Move 'Read All' button to the left side [#301](https://github.com/openkfw/TruBudget/issues/301) - Don't display view button if user is not allowed to see project/subproject [#302](https://github.com/openkfw/TruBudget/issues/302) - - - - ### Fixed - Empty history displayed after API call is finished [#294](https://github.com/openkfw/TruBudget/issues/294) @@ -32,8 +42,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Workflowitem amount is only displayed if amount and exchange rate are available [#297](https://github.com/openkfw/TruBudget/issues/297) - User is not logged out when viewing a workflow item's history [#317](https://github.com/openkfw/TruBudget/issues/317) - - ## [1.0.1] - 2019-05-21 ### Changed @@ -289,7 +297,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Updated translation keys and language-specific formatting. - Fixed bug where the subproject permissions dialog would break the details view of another project. -[unreleased]: https://github.com/openkfw/TruBudget/compare/v1.0.1...master +[unreleased]: https://github.com/openkfw/TruBudget/compare/v1.1.0...master +[1.1.0]: https://github.com/openkfw/TruBudget/compare/v1.0.1...v1.1.0 [1.0.1]: https://github.com/openkfw/TruBudget/compare/v1.0.0...v1.0.1 [1.0.0]: https://github.com/openkfw/TruBudget/compare/v1.0.0-beta.9...v1.0.0 [1.0.0-beta.9]: https://github.com/openkfw/TruBudget/compare/v1.0.0-beta.8...v1.0.0-beta.9 diff --git a/api/package-lock.json b/api/package-lock.json index d71093a20..88fd9dbf1 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -1,6 +1,6 @@ { "name": "trubudget-api", - "version": "1.0.1", + "version": "1.1.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/api/package.json b/api/package.json index 10961beed..e366dd75e 100644 --- a/api/package.json +++ b/api/package.json @@ -1,6 +1,6 @@ { "name": "trubudget-api", - "version": "1.0.1", + "version": "1.1.0", "private": true, "repository": { "type": "git", diff --git a/blockchain/package-lock.json b/blockchain/package-lock.json index 1ddba6e2c..53b296577 100644 --- a/blockchain/package-lock.json +++ b/blockchain/package-lock.json @@ -1,6 +1,6 @@ { "name": "trubudget-blockchain", - "version": "1.0.1", + "version": "1.1.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/blockchain/package.json b/blockchain/package.json index 1887c2297..6e9c5d9e6 100644 --- a/blockchain/package.json +++ b/blockchain/package.json @@ -1,6 +1,6 @@ { "name": "trubudget-blockchain", - "version": "1.0.1", + "version": "1.1.0", "private": true, "repository": { "type": "git", diff --git a/e2e-test/package-lock.json b/e2e-test/package-lock.json index d58963690..19feafb70 100644 --- a/e2e-test/package-lock.json +++ b/e2e-test/package-lock.json @@ -1,6 +1,6 @@ { "name": "trubudget-e2e-test", - "version": "1.0.1", + "version": "1.1.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/e2e-test/package.json b/e2e-test/package.json index e0f63c3fe..6c8e59d93 100644 --- a/e2e-test/package.json +++ b/e2e-test/package.json @@ -1,6 +1,6 @@ { "name": "trubudget-e2e-test", - "version": "1.0.1", + "version": "1.1.0", "private": true, "repository": { "type": "git", diff --git a/excel-export/package-lock.json b/excel-export/package-lock.json index 93333485f..633ae7aba 100644 --- a/excel-export/package-lock.json +++ b/excel-export/package-lock.json @@ -1,6 +1,6 @@ { "name": "excel-export", - "version": "1.0.1", + "version": "1.1.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/excel-export/package.json b/excel-export/package.json index 9b6306d91..43a134b97 100644 --- a/excel-export/package.json +++ b/excel-export/package.json @@ -1,6 +1,6 @@ { "name": "excel-export", - "version": "1.0.1", + "version": "1.1.0", "private": true, "description": "Export TruBudget data to Excel", "main": "src/index.js", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 938c2032d..bf655be45 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,6 +1,6 @@ { "name": "trubudget-frontend", - "version": "1.0.1", + "version": "1.1.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 674e5d023..639ad5ff6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "trubudget-frontend", - "version": "1.0.1", + "version": "1.1.0", "private": true, "repository": { "type": "git", diff --git a/provisioning/package-lock.json b/provisioning/package-lock.json index e6bdd62a9..d2220f28b 100644 --- a/provisioning/package-lock.json +++ b/provisioning/package-lock.json @@ -1,6 +1,6 @@ { "name": "trubudget-provisioning", - "version": "1.0.1", + "version": "1.1.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/provisioning/package.json b/provisioning/package.json index 8328a3540..65d8573ca 100644 --- a/provisioning/package.json +++ b/provisioning/package.json @@ -1,6 +1,6 @@ { "name": "trubudget-provisioning", - "version": "1.0.1", + "version": "1.1.0", "private": true, "repository": { "type": "git",