From 548dcc9c924a47d3c0bc7d2e7646888836b53d54 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Wed, 31 Jul 2019 14:51:19 -0700 Subject: [PATCH 01/45] Implement "Registered Accounts" admin page --- cloudformation/template.yaml | 19 +- dev-portal/package-lock.json | 46 ++- dev-portal/package.json | 2 +- dev-portal/src/__tests__/utils.jsx | 25 +- .../Admin/Accounts/AccountsTable.jsx | 327 +++++++++++++++ .../Admin/Accounts/AccountsTable.module.css | 3 + .../Admin/Accounts/AccountsTableColumns.jsx | 70 ++++ dev-portal/src/components/MessageList.jsx | 35 ++ .../pages/Admin/Accounts/AccountInvites.jsx | 7 + .../pages/Admin/Accounts/AccountRequests.jsx | 7 + .../pages/Admin/Accounts/AdminAccounts.jsx | 7 + .../Admin/Accounts/RegisteredAccounts.jsx | 245 +++++++++++ .../Accounts/__tests__/RegisteredAccounts.jsx | 381 ++++++++++++++++++ dev-portal/src/pages/Admin/Admin.jsx | 11 +- dev-portal/src/pages/Admin/SideNav.jsx | 15 +- dev-portal/src/services/accounts.js | 49 +++ 16 files changed, 1235 insertions(+), 14 deletions(-) create mode 100644 dev-portal/src/components/Admin/Accounts/AccountsTable.jsx create mode 100644 dev-portal/src/components/Admin/Accounts/AccountsTable.module.css create mode 100644 dev-portal/src/components/Admin/Accounts/AccountsTableColumns.jsx create mode 100644 dev-portal/src/components/MessageList.jsx create mode 100644 dev-portal/src/pages/Admin/Accounts/AccountInvites.jsx create mode 100644 dev-portal/src/pages/Admin/Accounts/AccountRequests.jsx create mode 100644 dev-portal/src/pages/Admin/Accounts/AdminAccounts.jsx create mode 100644 dev-portal/src/pages/Admin/Accounts/RegisteredAccounts.jsx create mode 100644 dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx create mode 100644 dev-portal/src/services/accounts.js diff --git a/cloudformation/template.yaml b/cloudformation/template.yaml index 7720f9d88..474b83685 100644 --- a/cloudformation/template.yaml +++ b/cloudformation/template.yaml @@ -20,6 +20,7 @@ Metadata: Parameters: - CognitoIdentityPoolName - DevPortalCustomersTableName + - AccountRegistrationMode - Label: default: "Subscription Notification Configuration" @@ -126,6 +127,15 @@ Parameters: - 'true' ConstraintDescription: Malformed input - Parameter DevelopmentMode value must be either 'true' or 'false' + AccountRegistrationMode: + Type: String + Description: Methods allowed for account registration. In 'open' mode, any user may register for an account. In 'request' mode, any user may request an account, but an Admin must approve the request in order for the account to perform any privileged actions (like subscribing to an API). In 'invite' mode, users cannot register or request an account; instead, an Admin must send an invite for the user to accept. See the documentation for details. + Default: 'open' + AllowedValues: + - 'open' + - 'request' + - 'invite' + Conditions: UseCustomDomainName: !And [!And [!Not [!Equals [!Ref CustomDomainName, '']], !Not [!Equals [!Ref CustomDomainNameAcmCertArn, '']]], !Condition NotDevelopmentMode] NoCustomDomainName: !And [!Not [ !Condition UseCustomDomainName ], !Condition NotDevelopmentMode] @@ -134,6 +144,7 @@ Conditions: DevelopmentMode: !Equals [!Ref DevelopmentMode, 'true'] NotDevelopmentMode: !Not [!Condition DevelopmentMode] InUSEastOne: !Equals [!Ref 'AWS::Region', 'us-east-1'] + InviteAccountRegistrationMode: !Equals [!Ref AccountRegistrationMode, 'invite'] Resources: ApiGatewayApi: @@ -1068,7 +1079,13 @@ Resources: Schema: - AttributeDataType: String Name: email - Required: false + Required: true + AdminCreateUserConfig: + AllowAdminCreateUserOnly: !If [ + InviteAccountRegistrationMode, 'true', 'false', + ] + AutoVerifiedAttributes: ['email'] + UsernameAttributes: ['email'] CognitoUserPoolClient: Type: AWS::Cognito::UserPoolClient diff --git a/dev-portal/package-lock.json b/dev-portal/package-lock.json index f31007d7d..4d60f5404 100644 --- a/dev-portal/package-lock.json +++ b/dev-portal/package-lock.json @@ -4441,6 +4441,15 @@ "object-assign": "^4.1.1" } }, + "create-react-context": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/create-react-context/-/create-react-context-0.2.2.tgz", + "integrity": "sha512-KkpaLARMhsTsgp0d2NA/R94F/eDLbhXERdIq3LvX2biCAXcDvHYoOqHfWCHf1+OLj+HKBotLG3KqaOOf+C1C+A==", + "requires": { + "fbjs": "^0.8.0", + "gud": "^1.0.0" + } + }, "cross-fetch": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-0.0.8.tgz", @@ -6813,6 +6822,11 @@ "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=", "dev": true }, + "gud": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/gud/-/gud-1.0.0.tgz", + "integrity": "sha512-zGEOVKFM5sVPPrYs7J5/hYEw2Pof8KCyOwyhG8sAF26mCAeUFAcYPu1mwB7hhpIP29zOIBaDqwuHdLp0jvZXjw==" + }, "gzip-size": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-5.0.0.tgz", @@ -10798,6 +10812,11 @@ "ts-pnp": "^1.0.0" } }, + "popper.js": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.15.0.tgz", + "integrity": "sha512-w010cY1oCUmI+9KwwlWki+r5jxKfTFDVoadl7MSrIujHU5MJ5OR6HTDj6Xo8aoR/QsA56x8jKjA59qGH4ELtrA==" + }, "portfinder": { "version": "1.0.20", "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.20.tgz", @@ -12228,6 +12247,19 @@ } } }, + "react-popper": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-1.3.3.tgz", + "integrity": "sha512-ynMZBPkXONPc5K4P5yFWgZx5JGAUIP3pGGLNs58cfAPgK67olx7fmLp+AdpZ0+GoQ+ieFDa/z4cdV6u7sioH6w==", + "requires": { + "@babel/runtime": "^7.1.2", + "create-react-context": "<=0.2.2", + "popper.js": "^1.14.4", + "prop-types": "^15.6.1", + "typed-styles": "^0.0.7", + "warning": "^4.0.2" + } + }, "react-redux": { "version": "4.4.10", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-4.4.10.tgz", @@ -13057,17 +13089,18 @@ } }, "semantic-ui-react": { - "version": "0.85.0", - "resolved": "https://registry.npmjs.org/semantic-ui-react/-/semantic-ui-react-0.85.0.tgz", - "integrity": "sha512-rroRoux+MMmLaZCZfFX9xZfTo1cy+JiM55fVaaqbfPq0s0RupraBhamJhvTDz7idl3ionaXPW7knJyZAz4XMGg==", + "version": "0.87.3", + "resolved": "https://registry.npmjs.org/semantic-ui-react/-/semantic-ui-react-0.87.3.tgz", + "integrity": "sha512-YJgFYEheeFBMm/epZpIpWKF9glgSShdLPiY8zoUi+KJ0IKtLtbI8RbMD/ELbZkY+SO/IWbK/f/86pWt3PVvMVA==", "requires": { "@babel/runtime": "^7.1.2", - "@semantic-ui-react/event-stack": "^3.0.1", + "@semantic-ui-react/event-stack": "^3.1.0", "classnames": "^2.2.6", "keyboard-key": "^1.0.4", "lodash": "^4.17.11", "prop-types": "^15.6.2", "react-is": "^16.7.0", + "react-popper": "^1.3.3", "shallowequal": "^1.1.0" } }, @@ -14457,6 +14490,11 @@ } } }, + "typed-styles": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/typed-styles/-/typed-styles-0.0.7.tgz", + "integrity": "sha512-pzP0PWoZUhsECYjABgCGQlRGL1n7tOHsgwYv3oIiEpJwGhFTuty/YNeduxQYzXXa3Ge5BdT6sHYIQYpl4uJ+5Q==" + }, "typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", diff --git a/dev-portal/package.json b/dev-portal/package.json index 04bede631..814d1d825 100644 --- a/dev-portal/package.json +++ b/dev-portal/package.json @@ -17,7 +17,7 @@ "react-markdown": "^4.0.3", "react-router-dom": "^4.3.1", "semantic-ui-css": "^2.4.1", - "semantic-ui-react": "0.85.0", + "semantic-ui-react": "^0.87.3", "swagger-ui": "git@github.com:Trial-In-Error/swagger-ui.git#a183e909ab467693cb1bbf87d5cc4d1e6b899579", "yamljs": "^0.3.0" }, diff --git a/dev-portal/src/__tests__/utils.jsx b/dev-portal/src/__tests__/utils.jsx index 42841f2a9..e5e7be117 100644 --- a/dev-portal/src/__tests__/utils.jsx +++ b/dev-portal/src/__tests__/utils.jsx @@ -6,7 +6,11 @@ import { render } from '@testing-library/react' /* * Jest requires at least one test per file in __tests__. */ -test('', () => {}) +if (typeof test === 'function') { + test('', () => {}) +} else { + console.warn('__tests__/utils used outside of tests!') +} /* * Wrapper around react-testing-library's `render` function, providing a dummy @@ -14,10 +18,19 @@ test('', () => {}) * * [1]: https://testing-library.com/docs/example-react-router */ -export const renderWithRouter = (ui, { - route = '/', - history = createMemoryHistory({ initialEntries: [route] }) -} = {}) => ({ +export const renderWithRouter = ( + ui, + { + route = '/', + history = createMemoryHistory({ initialEntries: [route] }), + } = {}, +) => ({ ...render({ui}), - history + history, }) + +/** + * Returns a Promise that resolves after `ms` milliseconds with the value `resolution`. + */ +export const resolveAfter = (ms, resolution) => + new Promise(resolve => setTimeout(() => resolve(resolution), ms)) diff --git a/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx b/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx new file mode 100644 index 000000000..68f411714 --- /dev/null +++ b/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx @@ -0,0 +1,327 @@ +import _ from 'lodash' +import React, { useCallback, useEffect, useState } from 'react' +import { + Container, + Dropdown, + Icon, + Input, + Pagination, + Placeholder, + Table, +} from 'semantic-ui-react' + +import styles from './AccountsTable.module.css' + +const FILLER_ACCOUNT = Symbol('FILLER_ACCOUNT') + +const NO_FILTER_COLUMN = Symbol('NO_FILTER_COLUMN') +const NO_FILTER_VALUE = '' +const NO_ORDER_COLUMN = Symbol('NO_ORDER_COLUMN') +const NO_ORDER_DIRECTION = Symbol('NO_ORDER_DIRECTION') + +const NEXT_DIRECTION = { + [NO_ORDER_DIRECTION]: 'asc', + asc: 'desc', + desc: NO_ORDER_DIRECTION, +} + +const DIRECTION_ICON = { + [NO_ORDER_DIRECTION]: 'sort', + asc: 'sort up', + desc: 'sort down', +} + +/** + * A paginated table whose rows represent accounts. + * + * @param {Object} props + * @param {Object[]} props.accounts + * all Account objects to display (before filtering) + * @param {AccountsTableColumns~Descriptor[]} props.columns + * column descriptors + * @param {boolean} props.loading + * if true, the table displays a loading state; if false, the table displays + * the given accounts + * @param {Object} props.selectedAccount + * an Account object to highlight + * @param onSelectAccount + * when the row corresponding to `account` is clicked, AccountsTable calls + * `onSelectAccount(account)` + * @param children + * components to be placed in the actions section above the table + */ +export const AccountsTable = ({ + accounts, + columns, + loading, + selectedAccount, + onSelectAccount, + children: toolbarActions, +}) => { + const pageSize = 10 + + const [accountsView, setAccountsView] = useState(accounts) + const [activePage, setActivePage] = useState(0) + const [activePageAccounts, setActivePageAccounts] = useState( + [...Array(pageSize)].fill(FILLER_ACCOUNT), + ) + + const [filterableColumns, setFilterableColumns] = useState([]) + const [filterColumn, setFilterColumn] = useState(NO_FILTER_COLUMN) + const [filterValue, setFilterValue] = useState(NO_FILTER_VALUE) + const [orderColumn, setOrderColumn] = useState(NO_ORDER_COLUMN) + const [orderDirection, setOrderDirection] = useState(NO_ORDER_DIRECTION) + + useEffect(() => { + const filterableColumns = columns.filter(column => column.filtering) + setFilterableColumns(filterableColumns) + + // Reset filtering state if no columns are filterable + if (filterableColumns.length === 0) { + setFilterColumn(NO_FILTER_COLUMN) + setFilterValue(NO_FILTER_VALUE) + } + + // Pick the first filterable column if one is available + else if (filterColumn === NO_FILTER_COLUMN) { + setFilterColumn(filterableColumns[0]) + } + + // Reset filterColumn if it's no longer among the available columns + else if (!filterableColumns.includes(filterColumn)) { + setFilterColumn(NO_FILTER_COLUMN) + } + }, [ + columns, + filterColumn, + setFilterColumn, + setFilterValue, + setFilterableColumns, + ]) + + /** + * Sets `accountsView` to the filtered and sorted subset of `props.accounts`. + */ + useEffect(() => { + let view = _(accounts) + if (filterColumn !== NO_FILTER_COLUMN) { + const filterKey = filterColumn.filtering.accessor + view = view.filter( + item => + !!item[filterKey] && item[filterKey].toString().includes(filterValue), + ) + } + if (orderColumn !== NO_ORDER_COLUMN) { + view = view.orderBy([orderColumn.ordering.iteratee], [orderDirection]) + } + setAccountsView(view.value()) + }, [accounts, filterColumn, filterValue, orderColumn, orderDirection]) + + /** + * Returns a page of accounts from `accountView` according to the given page + * number. + */ + const computeAccountsPage = useCallback( + activePage => { + const start = activePage * pageSize + const pageItems = accountsView.slice(start, start + pageSize) + const fillerCount = pageSize - pageItems.length + if (fillerCount) { + pageItems.push(...Array(fillerCount).fill(FILLER_ACCOUNT)) + } + return pageItems + }, + [accountsView], + ) + + const totalPages = Math.ceil(accountsView.length / pageSize) + + const onPageChange = useCallback( + (_event, { activePage: newActivePage }) => { + // SemanticUI uses 1-indexing in Pagination. We prefer sanity. + --newActivePage + setActivePage(newActivePage) + setActivePageAccounts(computeAccountsPage(newActivePage, accountsView)) + onSelectAccount(undefined) + }, + [accountsView, onSelectAccount, computeAccountsPage], + ) + + useEffect(() => { + loading || onPageChange(undefined, { activePage: 1 }) + }, [accounts, loading, onPageChange]) + + const tableRows = _.range(pageSize).map(index => { + if (loading) { + return + } + + const account = activePageAccounts[index] + return account === FILLER_ACCOUNT ? ( + + ) : ( + + ) + }) + + const filterColumnDropdownOptions = filterableColumns.map( + ({ title, id }, index) => ({ key: index, text: title, value: id }), + ) + + const onFilterColumnDropdownChange = (_event, { value }) => + setFilterColumn( + filterableColumns.find(column => column.id === value) || NO_FILTER_COLUMN, + ) + const onSearchInputChange = (_event, { value }) => setFilterValue(value) + + const toolbar = ( + <> +
+ {filterableColumns.length > 0 && ( + + )} +
+
+ +
+
+ {toolbarActions} +
+ + ) + + const table = ( + + + {tableRows} + + + + + + + + + +
+ ) + + return ( + + {toolbar} + {table} + + ) +} + +const TableHeader = React.memo( + ({ + columns, + orderColumn, + setOrderColumn, + orderDirection, + setOrderDirection, + }) => { + // Clicking on a column makes it the "orderColumn". If that column was + // already the "orderColumn", cycle between order directions (none, + // ascending, descending). Otherwise, start at the beginning of the cycle + // (ascending). + const onToggleOrder = column => () => { + if (column === orderColumn) { + const nextDirection = NEXT_DIRECTION[orderDirection] + if (nextDirection === NO_ORDER_DIRECTION) { + setOrderColumn(NO_ORDER_COLUMN) + } + setOrderDirection(nextDirection) + } else { + setOrderColumn(column) + setOrderDirection(NEXT_DIRECTION[NO_ORDER_DIRECTION]) + } + } + + return ( + + + {columns.map((column, index) => ( + + {column.title} + {column === orderColumn && ( + + )} + {column.ordering && column !== orderColumn && ( + + )} + + ))} + + + ) + }, +) + +const LoadingAccountRow = React.memo(({ columnCount }) => ( + + {Array.from({ length: columnCount }).map((_value, index) => ( + + +   + + + ))} + +)) + +const FillerAccountRow = React.memo(({ columnCount }) => ( + + {Array.from({ length: columnCount }).map((_value, index) => ( +   + ))} + +)) + +const AccountRow = React.memo(({ account, columns, isSelected, onSelect }) => { + return ( + onSelect(account)}> + {columns.map(({ render }, index) => ( + {render(account)} + ))} + + ) +}) diff --git a/dev-portal/src/components/Admin/Accounts/AccountsTable.module.css b/dev-portal/src/components/Admin/Accounts/AccountsTable.module.css new file mode 100644 index 000000000..41faba180 --- /dev/null +++ b/dev-portal/src/components/Admin/Accounts/AccountsTable.module.css @@ -0,0 +1,3 @@ +.headerRow { + user-select: none; +} diff --git a/dev-portal/src/components/Admin/Accounts/AccountsTableColumns.jsx b/dev-portal/src/components/Admin/Accounts/AccountsTableColumns.jsx new file mode 100644 index 000000000..84cca5030 --- /dev/null +++ b/dev-portal/src/components/Admin/Accounts/AccountsTableColumns.jsx @@ -0,0 +1,70 @@ +/** + * An AccountsTable column descriptor. + * + * @typedef {Object} AccountsTableColumns~Descriptor + * @property {string} id + * a unique ID to distinguish this column from others + * @property {string} title + * column title to show in the header row + * @property {Function} render + * accepts an Account object, and returns content to be placed in the table + * cell in this column + * @property {(Object|undefined)} ordering + * ordering descriptor for this column. If absent, the user cannot order on + * this column. + * @property ordering.iteratee + * a lodash iteratee, used with `lodash.orderBy` + * @property {(Object|undefined)} filtering + * filtering descriptor for this column. If absent, the user cannot filter + * on this column. + * @property {string} filtering.accessor + * an Account object property name on which to search + */ + +export const EmailAddress = { + id: 'emailAddress', + title: 'Email address', + render: account => account.emailAddress, + ordering: { + iteratee: 'emailAddress', + }, + filtering: { + accessor: 'emailAddress', + }, +} + +const DATE_TIME_FORMATTER = new Intl.DateTimeFormat('default', { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', +}) + +const formatDate = isoDateString => + DATE_TIME_FORMATTER.format(new Date(isoDateString)) + +export const DateRegistered = { + id: 'dateRegistered', + title: 'Date registered', + render: account => formatDate(account.dateRegistered), + ordering: { + iteratee: 'dateRegistered', + }, +} + +export const RegistrationMethod = { + id: 'registrationMethod', + title: 'Registration method', + render: account => account.registrationMethod, +} + +export const ApiKeyId = { + id: 'apiKeyId', + title: 'API key ID', + render: account => account.apiKeyId, + filtering: { + accessor: 'apiKeyId', + }, +} diff --git a/dev-portal/src/components/MessageList.jsx b/dev-portal/src/components/MessageList.jsx new file mode 100644 index 000000000..2500ca2ac --- /dev/null +++ b/dev-portal/src/components/MessageList.jsx @@ -0,0 +1,35 @@ +import React, { useState } from 'react' + +export const MessageList = ({ messages, dismissMessage, renderers }) => ( + <> + {messages.map((message, index) => { + const { type, ...payload } = message + if (renderers[type]) { + return ( + + {renderers[type](payload, () => dismissMessage(message))} + + ) + } + throw new Error(`Unknown message type: ${type.toString()}`) + })} + +) + +export const useMessageQueue = initialMessages => { + const [messages, setMessages] = useState(initialMessages || []) + + const sendMessage = target => setMessages([...messages, target]) + const dismissMessage = target => { + const deleteIndex = messages.findIndex(message => message === target) + if (deleteIndex === -1) { + throw new Error('Message not found') + } + setMessages([ + ...messages.slice(0, deleteIndex), + ...messages.slice(deleteIndex + 1), + ]) + } + + return [messages, sendMessage, dismissMessage] +} diff --git a/dev-portal/src/pages/Admin/Accounts/AccountInvites.jsx b/dev-portal/src/pages/Admin/Accounts/AccountInvites.jsx new file mode 100644 index 000000000..98209e757 --- /dev/null +++ b/dev-portal/src/pages/Admin/Accounts/AccountInvites.jsx @@ -0,0 +1,7 @@ +import React, { Component } from 'react' + +export default class AccountInvites extends Component { + render = () => { + return

TODO: Account invites

+ } +} diff --git a/dev-portal/src/pages/Admin/Accounts/AccountRequests.jsx b/dev-portal/src/pages/Admin/Accounts/AccountRequests.jsx new file mode 100644 index 000000000..85930af11 --- /dev/null +++ b/dev-portal/src/pages/Admin/Accounts/AccountRequests.jsx @@ -0,0 +1,7 @@ +import React, { Component } from 'react' + +export default class AccountRequests extends Component { + render = () => { + return

TODO: Account requests

+ } +} diff --git a/dev-portal/src/pages/Admin/Accounts/AdminAccounts.jsx b/dev-portal/src/pages/Admin/Accounts/AdminAccounts.jsx new file mode 100644 index 000000000..bde963471 --- /dev/null +++ b/dev-portal/src/pages/Admin/Accounts/AdminAccounts.jsx @@ -0,0 +1,7 @@ +import React, { Component } from 'react' + +export default class AdminAccounts extends Component { + render = () => { + return

TODO: Admin accounts

+ } +} diff --git a/dev-portal/src/pages/Admin/Accounts/RegisteredAccounts.jsx b/dev-portal/src/pages/Admin/Accounts/RegisteredAccounts.jsx new file mode 100644 index 000000000..964e69e36 --- /dev/null +++ b/dev-portal/src/pages/Admin/Accounts/RegisteredAccounts.jsx @@ -0,0 +1,245 @@ +import React, { useCallback, useEffect, useState } from 'react' +import { Button, Container, Header, Message, Modal } from 'semantic-ui-react' + +import * as MessageList from 'components/MessageList' +import * as AccountService from 'services/accounts' +import * as AccountsTable from 'components/Admin/Accounts/AccountsTable' +import * as AccountsTableColumns from 'components/Admin/Accounts/AccountsTableColumns' + +const RegisteredAccounts = () => { + const [accounts, setAccounts] = useState([]) + const [loading, setLoading] = useState(true) + const [selectedAccount, setSelectedAccount] = useState(undefined) + const [deleteModalOpen, setDeleteModalOpen] = useState(false) + const [promoteModalOpen, setPromoteModalOpen] = useState(false) + const [messages, sendMessage, dismissMessage] = MessageList.useMessageQueue() + + const refreshAccounts = () => + AccountService.fetchRegisteredAccounts().then(accounts => + setAccounts(accounts), + ) + + // Initial load + useEffect(() => { + refreshAccounts().finally(() => setLoading(false)) + }, []) + + const onSelectAccount = useCallback(account => setSelectedAccount(account), [ + setSelectedAccount, + ]) + + const onConfirmDelete = useCallback(async () => { + setLoading(true) + setDeleteModalOpen(false) + try { + await AccountService.deleteAccountByIdentityPoolId( + selectedAccount.identityPoolId, + ) + sendMessage({ type: DELETE_SUCCESS, account: selectedAccount }) + await refreshAccounts() + } catch (error) { + sendMessage({ + type: DELETE_FAILURE, + account: selectedAccount, + errorMessage: error.message, + }) + } finally { + setLoading(false) + } + }, [sendMessage, selectedAccount]) + + const onConfirmPromote = useCallback(async () => { + setLoading(true) + setPromoteModalOpen(false) + try { + await AccountService.promoteAccountByIdentityPoolId( + selectedAccount.identityPoolId, + ) + sendMessage({ type: PROMOTE_SUCCESS, account: selectedAccount }) + } catch (error) { + sendMessage({ + type: PROMOTE_FAILURE, + account: selectedAccount, + errorMessage: error.message, + }) + } finally { + setLoading(false) + } + }, [sendMessage, selectedAccount]) + + + return ( + +
Registered accounts
+ + + setDeleteModalOpen(true)} + canPromote={!loading && selectedAccount} + onClickPromote={() => setPromoteModalOpen(true)} + /> + + setDeleteModalOpen(false)} + /> + setPromoteModalOpen(false)} + /> +
+ ) +} +export default RegisteredAccounts + +const TableActions = React.memo( + ({ canDelete, onClickDelete, canPromote, onClickPromote }) => ( + + + + + + ), +) + +const PromoteAccountModal = React.memo( + ({ account, onConfirm, open, onClose }) => + account && ( + + Confirm promotion + +

+ Are you sure you want to promote the account{' '} + {account.emailAddress} to Admin? This will allow + the account to perform any Admin actions, including deleting and + promoting other accounts. +

+

+ This action can only be undone by + contacting the owner of the Developer Portal. +

+
+ + + + +
+ ), +) + +const DELETE_SUCCESS = Symbol('DELETE_SUCCESS') +const DELETE_FAILURE = Symbol('DELETE_FAILURE') +const PROMOTE_SUCCESS = Symbol('PROMOTE_SUCCESS') +const PROMOTE_FAILURE = Symbol('PROMOTE_FAILURE') + +const MESSAGE_RENDERERS = { + [DELETE_SUCCESS]: ({ account }, onDismiss) => ( + + ), + [DELETE_FAILURE]: ({ account, errorMessage }, onDismiss) => ( + + ), + [PROMOTE_SUCCESS]: ({ account }, onDismiss) => ( + + ), + [PROMOTE_FAILURE]: ({ account, errorMessage }, onDismiss) => ( + + ), +} + +const DeleteSuccessMessage = React.memo(({ account, onDismiss }) => ( + + + Deleted account {account.emailAddress}. + + +)) + +const DeleteFailureMessage = React.memo( + ({ account, errorMessage, onDismiss }) => ( + + +

+ Failed to delete account {account.emailAddress}. +

+ {errorMessage &&

Error message: {errorMessage}

} +
+
+ ), +) + +const PromoteSuccessMessage = React.memo(({ account, onDismiss }) => ( + + + Promoted account {account.emailAddress}. + + +)) + +const PromoteFailureMessage = React.memo( + ({ account, errorMessage, onDismiss }) => ( + + +

+ Failed to promote account {account.emailAddress}. +

+ {errorMessage &&

Error message: {errorMessage}

} +
+
+ ), +) diff --git a/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx b/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx new file mode 100644 index 000000000..13c43c3cf --- /dev/null +++ b/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx @@ -0,0 +1,381 @@ +import _ from 'lodash' +import React from 'react' +import * as rtl from '@testing-library/react' +import '@testing-library/jest-dom/extend-expect' + +import { renderWithRouter } from '__tests__/utils' + +import RegisteredAccounts from 'pages/Admin/Accounts/RegisteredAccounts' +import * as AccountService from 'services/accounts' + +jest.mock('services/accounts') + +/** + * Suppress React 16.8 act() warnings globally. + * The React team's fix won't be out of alpha until 16.9.0. + * + * See + */ +const consoleError = console.error +beforeAll(() => { + jest.spyOn(console, 'error').mockImplementation((...args) => { + if ( + !args[0].includes( + 'Warning: An update to %s inside a test was not wrapped in act', + ) + ) { + consoleError(...args) + } + }) +}) + +afterEach(rtl.cleanup) + +const renderPage = () => renderWithRouter() + +const waitForAccountsToLoad = page => + rtl.waitForElementToBeRemoved(() => + page.queryAllByTestId('accountRowPlaceholder'), + ) + +describe('RegisteredAccounts page', () => { + it('renders', async () => { + AccountService.fetchRegisteredAccounts = jest.fn().mockResolvedValue([]) + const page = renderPage() + expect(page.baseElement).toBeTruthy() + }) + + it('initially shows the loading state', async () => { + AccountService.fetchRegisteredAccounts = jest + .fn() + .mockReturnValue(new Promise(() => {})) + + const page = renderPage() + expect(page.queryAllByTestId('accountRowPlaceholder')).not.toHaveLength(0) + }) + + it('shows the accounts after loading', async () => { + AccountService.fetchRegisteredAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ACCOUNTS) + const page = renderPage() + await waitForAccountsToLoad(page) + + _.range(10).forEach(index => + expect(page.queryByText(`${index}@example.com`)).not.toBeNull(), + ) + }) + + it('orders pages for all accounts', async () => { + AccountService.fetchRegisteredAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ACCOUNTS) + + const page = renderPage() + await waitForAccountsToLoad(page) + const pagination = page.getByRole('navigation') + + const page1Button = rtl.queryByText(pagination, '1') + expect(page1Button).not.toBeNull() + + const page16Button = rtl.queryByText(pagination, '16') + expect(page16Button).not.toBeNull() + rtl.fireEvent.click(page16Button) + expect( + page.queryByText(`${NUM_MOCK_ACCOUNTS - 1}@example.com`), + ).not.toBeNull() + }) + + it('orders accounts by email address', async () => { + AccountService.fetchRegisteredAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ACCOUNTS) + + const page = renderPage() + await waitForAccountsToLoad(page) + + // Order ascending + const table = page.getByTestId('accountsTable') + const emailAddressHeader = rtl.getByText(table, 'Email address') + rtl.fireEvent.click(emailAddressHeader) + + // Check that first page is correct + ;[0, ..._.range(100, 109)] + .map(index => `${index}@example.com`) + .forEach(emailAddress => rtl.getByText(table, emailAddress)) + + // Check that last page is correct + const pagination = page.getByRole('navigation') + const lastPageButton = rtl.getByLabelText(pagination, 'Last item') + rtl.fireEvent.click(lastPageButton) + ;[..._.range(94, 100), 9] + .map(index => `${index}@example.com`) + .forEach(emailAddress => rtl.getByText(table, emailAddress)) + + // Order descending, go back to first page + rtl.fireEvent.click(emailAddressHeader) + const firstPageButton = rtl.getByLabelText(pagination, 'First item') + rtl.fireEvent.click(firstPageButton) + + // Check that first page is correct + ;[9, ..._.range(99, 90)] + .map(index => `${index}@example.com`) + .forEach(emailAddress => rtl.getByText(table, emailAddress)) + }) + + it('orders accounts by date registered', async () => { + AccountService.fetchRegisteredAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ACCOUNTS) + + const page = renderPage() + await waitForAccountsToLoad(page) + + // Order ascending + const table = page.getByTestId('accountsTable') + const dateRegisteredHeader = rtl.getByText(table, 'Date registered') + rtl.fireEvent.click(dateRegisteredHeader) + + // Check that first page is correct + ;[0, 105, 53, 1, 106, 54, 2, 107, 55, 3] + .map(index => `${index}@example.com`) + .forEach(emailAddress => rtl.getByText(table, emailAddress)) + }) + + it('filters accounts by email address', async () => { + AccountService.fetchRegisteredAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ACCOUNTS) + + const page = renderPage() + await waitForAccountsToLoad(page) + const filterInput = page.getByPlaceholderText('Search by...') + const table = page.getByTestId('accountsTable') + + rtl.fireEvent.change(filterInput, { target: { value: '11' } }) + ;[11, ..._.range(110, 119)] + .map(index => `${index}@example.com`) + .forEach(emailAddress => rtl.getByText(table, emailAddress)) + + rtl.fireEvent.change(filterInput, { target: { value: '111' } }) + rtl.getByText(table, '111@example.com') + expect(rtl.getAllByText(table, /@example\.com/)).toHaveLength(1) + + rtl.fireEvent.change(filterInput, { target: { value: 'apiKeyId' } }) + expect(rtl.queryAllByText(table, /@example\.com/)).toHaveLength(0) + }) + + it('filters accounts by API key', async () => { + AccountService.fetchRegisteredAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ACCOUNTS) + + const page = renderPage() + await waitForAccountsToLoad(page) + const filterInput = page.getByPlaceholderText('Search by...') + const filterDropdown = page.getByTestId('filterDropdown') + const table = page.getByTestId('accountsTable') + + rtl.fireEvent.click(filterDropdown) + const filterByApiKeyIdOption = rtl.getByText(filterDropdown, 'API key ID') + rtl.fireEvent.click(filterByApiKeyIdOption) + + rtl.fireEvent.change(filterInput, { target: { value: '15' } }) + ;[15, 115, ..._.range(150, 157)] + .map(index => `apiKeyId${index}`) + .forEach(apiKeyId => rtl.getByText(table, apiKeyId)) + + rtl.fireEvent.change(filterInput, { target: { value: '155' } }) + rtl.getByText(table, 'apiKeyId155') + expect(rtl.getAllByText(table, /apiKeyId/)).toHaveLength(1) + + rtl.fireEvent.change(filterInput, { target: { value: '@example.com' } }) + expect(rtl.queryAllByText(table, /apiKeyId/)).toHaveLength(0) + }) + + it('filters and orders at the same time', async () => { + AccountService.fetchRegisteredAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ACCOUNTS) + + const page = renderPage() + await waitForAccountsToLoad(page) + const filterInput = page.getByPlaceholderText('Search by...') + const table = page.getByTestId('accountsTable') + const dateRegisteredHeader = rtl.getByText(table, 'Date registered') + + rtl.fireEvent.change(filterInput, { target: { value: '13' } }) + rtl.fireEvent.click(dateRegisteredHeader) + ;[113, 13, ..._.range(131, 138)] + .map(index => `apiKeyId${index}`) + .forEach(apiKeyId => rtl.getByText(table, apiKeyId)) + }) + + it('deletes an account', async () => { + const targetAccountEmail = '1@example.com' + const targetAccountIdentityPoolId = 'identityPoolId1' + + AccountService.fetchRegisteredAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ACCOUNTS) + .mockResolvedValueOnce( + MOCK_ACCOUNTS.filter( + account => account.emailAddress !== targetAccountEmail, + ), + ) + AccountService.deleteAccountByIdentityPoolId = jest + .fn() + .mockResolvedValueOnce(undefined) + + const page = renderPage() + await waitForAccountsToLoad(page) + const table = page.getByTestId('accountsTable') + const targetAccountEmailCell = rtl.getByText(table, targetAccountEmail) + const deleteButton = page.getByText('Delete') + + expect(deleteButton.disabled === true) + rtl.fireEvent.click(targetAccountEmailCell) + expect(deleteButton.disabled === false) + rtl.fireEvent.click(deleteButton) + + const modal = rtl.getByText(document, 'Confirm deletion').closest('.modal') + const confirmDeleteButton = rtl.getByText(modal, 'Delete') + rtl.getByText(modal, targetAccountEmail) + rtl.fireEvent.click(confirmDeleteButton) + + await waitForAccountsToLoad(page) + expect(rtl.queryByText(document, 'Confirm deletion')).toBeNull() + expect( + AccountService.deleteAccountByIdentityPoolId.mock.calls, + ).toHaveLength(1) + expect( + AccountService.deleteAccountByIdentityPoolId.mock.calls[0][0], + ).toEqual(targetAccountIdentityPoolId) + + await rtl.wait(() => + expect(page.getByText(/Deleted account/)).toBeInTheDocument(), + ) + expect(rtl.queryByText(table, targetAccountEmail)).toBeNull() + }) + + it('shows a message when deletion fails', async () => { + const targetAccountEmail = '1@example.com' + const errorMessage = 'Something weird happened!' + + AccountService.fetchRegisteredAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ACCOUNTS) + AccountService.deleteAccountByIdentityPoolId = jest + .fn() + .mockImplementation(() => Promise.reject(new Error(errorMessage))) + + const page = renderPage() + await waitForAccountsToLoad(page) + const table = page.getByTestId('accountsTable') + const targetAccountEmailCell = rtl.getByText(table, targetAccountEmail) + const deleteButton = page.getByText('Delete') + rtl.fireEvent.click(targetAccountEmailCell) + rtl.fireEvent.click(deleteButton) + + const modal = rtl.getByText(document, 'Confirm deletion').closest('.modal') + const confirmDeleteButton = rtl.getByText(modal, 'Delete') + rtl.getByText(modal, targetAccountEmail) + rtl.fireEvent.click(confirmDeleteButton) + + await waitForAccountsToLoad(page) + await rtl.wait(() => + expect(page.getByText(/Failed to delete account/)).toBeInTheDocument(), + ) + expect(rtl.getByText(table, targetAccountEmail)).toBeInTheDocument() + }) + + it('promotes an account', async () => { + const targetAccountEmail = '2@example.com' + const targetAccountIdentityPoolId = 'identityPoolId2' + + AccountService.fetchRegisteredAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ACCOUNTS) + AccountService.promoteAccountByIdentityPoolId = jest + .fn() + .mockResolvedValueOnce(undefined) + + const page = renderPage() + await waitForAccountsToLoad(page) + const table = page.getByTestId('accountsTable') + const targetAccountEmailCell = rtl.getByText(table, targetAccountEmail) + const promoteButton = page.getByText('Promote to Admin') + + expect(promoteButton.disabled === true) + rtl.fireEvent.click(targetAccountEmailCell) + expect(promoteButton.disabled === false) + rtl.fireEvent.click(promoteButton) + + const modal = rtl.getByText(document, 'Confirm promotion').closest('.modal') + const confirmPromoteButton = rtl.getByText(modal, 'Promote') + rtl.getByText(modal, targetAccountEmail) + rtl.fireEvent.click(confirmPromoteButton) + + await waitForAccountsToLoad(page) + expect(rtl.queryByText(document, 'Confirm promotion')).toBeNull() + expect( + AccountService.promoteAccountByIdentityPoolId.mock.calls, + ).toHaveLength(1) + expect( + AccountService.promoteAccountByIdentityPoolId.mock.calls[0][0], + ).toEqual(targetAccountIdentityPoolId) + + await rtl.wait(() => + expect(page.getByText(/Promoted account/)).toBeInTheDocument(), + ) + expect(rtl.getByText(table, targetAccountEmail)).toBeInTheDocument() + }) + + it('shows a message when promotion fails', async () => { + const targetAccountEmail = '2@example.com' + const errorMessage = 'Something strange occurred.' + + AccountService.fetchRegisteredAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ACCOUNTS) + AccountService.deleteAccountByIdentityPoolId = jest + .fn() + .mockImplementation(() => Promise.reject(new Error(errorMessage))) + + const page = renderPage() + await waitForAccountsToLoad(page) + const table = page.getByTestId('accountsTable') + const targetAccountEmailCell = rtl.getByText(table, targetAccountEmail) + const deleteButton = page.getByText('Delete') + rtl.fireEvent.click(targetAccountEmailCell) + rtl.fireEvent.click(deleteButton) + + const modal = rtl.getByText(document, 'Confirm deletion').closest('.modal') + const confirmDeleteButton = rtl.getByText(modal, 'Delete') + rtl.getByText(modal, targetAccountEmail) + rtl.fireEvent.click(confirmDeleteButton) + + await waitForAccountsToLoad(page) + await rtl.wait(() => + expect(page.getByText(/Failed to delete account/)).toBeInTheDocument(), + ) + expect(rtl.getByText(table, targetAccountEmail)).toBeInTheDocument() + }) +}) + +const NUM_MOCK_ACCOUNTS = 157 // should be prime + +const MOCK_ACCOUNTS = (() => { + const now = Date.now() + return Array.from({ length: NUM_MOCK_ACCOUNTS }).map((_value, index) => ({ + identityPoolId: `identityPoolId${index}`, + userPoolId: `userPoolId${index}`, + emailAddress: `${index}@example.com`, + dateRegistered: new Date( + now + ((index * 3) % NUM_MOCK_ACCOUNTS) * 1000, + ).toJSON(), + apiKeyId: `apiKeyId${index}`, + registrationMethod: _.sample(['open', 'invite', 'request']), + isAdmin: index % 20 === 0, + })) +})() diff --git a/dev-portal/src/pages/Admin/Admin.jsx b/dev-portal/src/pages/Admin/Admin.jsx index 61dee895b..87d80ba04 100644 --- a/dev-portal/src/pages/Admin/Admin.jsx +++ b/dev-portal/src/pages/Admin/Admin.jsx @@ -2,7 +2,12 @@ import React, { Component } from 'react' import { BrowserRouter as Router } from 'react-router-dom' import { ApiManagement, SideNav } from './' -import { AdminRoute } from './../../'; +import { AdminRoute } from './../../' + +import RegisteredAccounts from 'pages/Admin/Accounts/RegisteredAccounts' +import AdminAccounts from 'pages/Admin/Accounts/AdminAccounts' +import AccountInvites from 'pages/Admin/Accounts/AccountInvites' +import AccountRequests from 'pages/Admin/Accounts/AccountRequests' export class Admin extends Component { render() { @@ -13,6 +18,10 @@ export class Admin extends Component {
+ + + +
diff --git a/dev-portal/src/pages/Admin/SideNav.jsx b/dev-portal/src/pages/Admin/SideNav.jsx index a798ad8c3..7f3e984ce 100644 --- a/dev-portal/src/pages/Admin/SideNav.jsx +++ b/dev-portal/src/pages/Admin/SideNav.jsx @@ -7,10 +7,23 @@ import { observer } from 'mobx-react' import { Link } from 'react-router-dom' import { Menu } from 'semantic-ui-react' - export const SideNav = observer(() => ( isAdmin() && ( APIs + + Accounts + + + Admins + + + Invites + + + Requests + + + ) )) diff --git a/dev-portal/src/services/accounts.js b/dev-portal/src/services/accounts.js new file mode 100644 index 000000000..d7fe392ef --- /dev/null +++ b/dev-portal/src/services/accounts.js @@ -0,0 +1,49 @@ +import _ from 'lodash' + +import { resolveAfter } from '__tests__/utils' + +const now = Date.now() +const numMockAccounts = 157 // should be prime +const mockData = Array.from({ length: numMockAccounts }).map( + (_value, index) => ({ + identityPoolId: `identityPoolId${index}`, + userPoolId: `userPoolId${index}`, + emailAddress: `${index}@example.com`, + dateRegistered: new Date( + now + ((index * 3) % numMockAccounts) * 1000, + ).toJSON(), + apiKeyId: `apiKeyId${index}`, + registrationMethod: _.sample(['open', 'invite', 'request']), + isAdmin: index % 20 === 0, + }), +) + +export const fetchRegisteredAccounts = () => { + return resolveAfter(1500, mockData.slice()) +} + +export const deleteAccountByIdentityPoolId = async identityPoolId => { + await resolveAfter(1500) + + const accountIndex = mockData.findIndex(account => account.identityPoolId === identityPoolId) + if (accountIndex === -1) { + throw new Error('Account not found!') + } + if (identityPoolId.endsWith('10')) { + throw new Error('Something weird happened!') + } + mockData.splice(accountIndex, 1) +} + +export const promoteAccountByIdentityPoolId = async identityPoolId => { + await resolveAfter(1500) + + const account = mockData.find(account => account.identityPoolId === identityPoolId) + if (account === undefined) { + throw new Error('Account not found!') + } + if (account.isAdmin) { + throw new Error('Account is already an Admin!') + } + account.isAdmin = true +} From 6b89bf46970d3c3cb698e7af16f57de3fb00d939 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Thu, 1 Aug 2019 14:07:04 -0700 Subject: [PATCH 02/45] Move test utils out of real-test scope --- .../Admin/Accounts/__tests__/RegisteredAccounts.jsx | 2 +- dev-portal/src/pages/__tests__/Home.jsx | 8 ++++---- dev-portal/src/services/accounts.js | 2 +- .../src/{__tests__/utils.jsx => utils/test-utils.jsx} | 9 ++------- 4 files changed, 8 insertions(+), 13 deletions(-) rename dev-portal/src/{__tests__/utils.jsx => utils/test-utils.jsx} (81%) diff --git a/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx b/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx index 13c43c3cf..9bdbb27a5 100644 --- a/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx +++ b/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx @@ -3,7 +3,7 @@ import React from 'react' import * as rtl from '@testing-library/react' import '@testing-library/jest-dom/extend-expect' -import { renderWithRouter } from '__tests__/utils' +import { renderWithRouter } from 'utils/test-utils' import RegisteredAccounts from 'pages/Admin/Accounts/RegisteredAccounts' import * as AccountService from 'services/accounts' diff --git a/dev-portal/src/pages/__tests__/Home.jsx b/dev-portal/src/pages/__tests__/Home.jsx index 719fc5768..bddb87567 100644 --- a/dev-portal/src/pages/__tests__/Home.jsx +++ b/dev-portal/src/pages/__tests__/Home.jsx @@ -1,12 +1,12 @@ import React from 'react' -import {cleanup} from '@testing-library/react' +import { cleanup } from '@testing-library/react' import '@testing-library/jest-dom/extend-expect' -import {renderWithRouter} from '__tests__/utils' +import { renderWithRouter } from 'utils/test-utils' -import {fragments} from 'services/get-fragments' +import { fragments } from 'services/get-fragments' -import {HomePage} from 'pages/Home' +import { HomePage } from 'pages/Home' beforeEach(() => { // Mock fragment diff --git a/dev-portal/src/services/accounts.js b/dev-portal/src/services/accounts.js index d7fe392ef..bdccf8625 100644 --- a/dev-portal/src/services/accounts.js +++ b/dev-portal/src/services/accounts.js @@ -1,6 +1,6 @@ import _ from 'lodash' -import { resolveAfter } from '__tests__/utils' +import { resolveAfter } from 'utils/test-utils' const now = Date.now() const numMockAccounts = 157 // should be prime diff --git a/dev-portal/src/__tests__/utils.jsx b/dev-portal/src/utils/test-utils.jsx similarity index 81% rename from dev-portal/src/__tests__/utils.jsx rename to dev-portal/src/utils/test-utils.jsx index e5e7be117..b268aef89 100644 --- a/dev-portal/src/__tests__/utils.jsx +++ b/dev-portal/src/utils/test-utils.jsx @@ -3,13 +3,8 @@ import { Router } from 'react-router-dom' import { createMemoryHistory } from 'history' import { render } from '@testing-library/react' -/* - * Jest requires at least one test per file in __tests__. - */ -if (typeof test === 'function') { - test('', () => {}) -} else { - console.warn('__tests__/utils used outside of tests!') +if (typeof jest !== 'object') { + console.warn('test-utils used outside of tests!') } /* From 6db1511eff0d29ec1d1545d97e78602598a016c3 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Mon, 5 Aug 2019 11:18:30 -0700 Subject: [PATCH 03/45] AccountsTable: refactor order directions to use circular array --- .../Admin/Accounts/AccountsTable.jsx | 56 +++++++++++-------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx b/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx index 68f411714..08701e9ec 100644 --- a/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx +++ b/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx @@ -17,19 +17,23 @@ const FILLER_ACCOUNT = Symbol('FILLER_ACCOUNT') const NO_FILTER_COLUMN = Symbol('NO_FILTER_COLUMN') const NO_FILTER_VALUE = '' const NO_ORDER_COLUMN = Symbol('NO_ORDER_COLUMN') -const NO_ORDER_DIRECTION = Symbol('NO_ORDER_DIRECTION') -const NEXT_DIRECTION = { - [NO_ORDER_DIRECTION]: 'asc', - asc: 'desc', - desc: NO_ORDER_DIRECTION, -} +const ORDER_DIRECTIONS = [ + { + lodashDirection: undefined, + iconName: 'sort', + }, + { + lodashDirection: 'asc', + iconName: 'sort up', + }, + { + lodashDirection: 'desc', + iconName: 'sort down', + }, +] -const DIRECTION_ICON = { - [NO_ORDER_DIRECTION]: 'sort', - asc: 'sort up', - desc: 'sort down', -} +const nextDirectionIndex = index => (index + 1) % ORDER_DIRECTIONS.length /** * A paginated table whose rows represent accounts. @@ -70,7 +74,7 @@ export const AccountsTable = ({ const [filterColumn, setFilterColumn] = useState(NO_FILTER_COLUMN) const [filterValue, setFilterValue] = useState(NO_FILTER_VALUE) const [orderColumn, setOrderColumn] = useState(NO_ORDER_COLUMN) - const [orderDirection, setOrderDirection] = useState(NO_ORDER_DIRECTION) + const [orderDirectionIndex, setOrderDirectionIndex] = useState(0) useEffect(() => { const filterableColumns = columns.filter(column => column.filtering) @@ -112,10 +116,13 @@ export const AccountsTable = ({ ) } if (orderColumn !== NO_ORDER_COLUMN) { - view = view.orderBy([orderColumn.ordering.iteratee], [orderDirection]) + view = view.orderBy( + [orderColumn.ordering.iteratee], + [ORDER_DIRECTIONS[orderDirectionIndex].lodashDirection], + ) } setAccountsView(view.value()) - }, [accounts, filterColumn, filterValue, orderColumn, orderDirection]) + }, [accounts, filterColumn, filterValue, orderColumn, orderDirectionIndex]) /** * Returns a page of accounts from `accountView` according to the given page @@ -219,8 +226,8 @@ export const AccountsTable = ({ columns={columns} orderColumn={orderColumn} setOrderColumn={setOrderColumn} - orderDirection={orderDirection} - setOrderDirection={setOrderDirection} + orderDirectionIndex={orderDirectionIndex} + setOrderDirectionIndex={setOrderDirectionIndex} /> {tableRows} @@ -253,8 +260,8 @@ const TableHeader = React.memo( columns, orderColumn, setOrderColumn, - orderDirection, - setOrderDirection, + orderDirectionIndex, + setOrderDirectionIndex, }) => { // Clicking on a column makes it the "orderColumn". If that column was // already the "orderColumn", cycle between order directions (none, @@ -262,17 +269,18 @@ const TableHeader = React.memo( // (ascending). const onToggleOrder = column => () => { if (column === orderColumn) { - const nextDirection = NEXT_DIRECTION[orderDirection] - if (nextDirection === NO_ORDER_DIRECTION) { + const nextIndex = nextDirectionIndex(orderDirectionIndex) + if (nextIndex === 0) { setOrderColumn(NO_ORDER_COLUMN) } - setOrderDirection(nextDirection) + setOrderDirectionIndex(nextIndex) } else { setOrderColumn(column) - setOrderDirection(NEXT_DIRECTION[NO_ORDER_DIRECTION]) + setOrderDirectionIndex(nextDirectionIndex(0)) } } + const orderDirection = ORDER_DIRECTIONS[orderDirectionIndex] return ( @@ -283,10 +291,10 @@ const TableHeader = React.memo( > {column.title} {column === orderColumn && ( - + )} {column.ordering && column !== orderColumn && ( - + )} ))} From b2d94f54e653fb4ac8edad556cfd23743e7a4e7f Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Mon, 5 Aug 2019 11:25:21 -0700 Subject: [PATCH 04/45] RegisteredAccounts: clarified wording in delete/promote modals --- dev-portal/src/pages/Admin/Accounts/RegisteredAccounts.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dev-portal/src/pages/Admin/Accounts/RegisteredAccounts.jsx b/dev-portal/src/pages/Admin/Accounts/RegisteredAccounts.jsx index 964e69e36..57c479838 100644 --- a/dev-portal/src/pages/Admin/Accounts/RegisteredAccounts.jsx +++ b/dev-portal/src/pages/Admin/Accounts/RegisteredAccounts.jsx @@ -134,7 +134,7 @@ const DeleteAccountModal = React.memo(

Are you sure you want to delete the account{' '} {account.emailAddress}, and de-activate the - associated API key? This action cannot be undone. + associated API key? This action is irreversible.

@@ -160,8 +160,8 @@ const PromoteAccountModal = React.memo( promoting other accounts.

- This action can only be undone by - contacting the owner of the Developer Portal. + Only the owner of the Developer Portal can demote the account, + through the Cognito console.

From 8c26a9103a27c0dbff08736eeef9957866df7c89 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Mon, 5 Aug 2019 11:26:42 -0700 Subject: [PATCH 05/45] RegisteredAccounts: move message types to top of file --- .../src/pages/Admin/Accounts/RegisteredAccounts.jsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/dev-portal/src/pages/Admin/Accounts/RegisteredAccounts.jsx b/dev-portal/src/pages/Admin/Accounts/RegisteredAccounts.jsx index 57c479838..c2de56a99 100644 --- a/dev-portal/src/pages/Admin/Accounts/RegisteredAccounts.jsx +++ b/dev-portal/src/pages/Admin/Accounts/RegisteredAccounts.jsx @@ -6,6 +6,11 @@ import * as AccountService from 'services/accounts' import * as AccountsTable from 'components/Admin/Accounts/AccountsTable' import * as AccountsTableColumns from 'components/Admin/Accounts/AccountsTableColumns' +const DELETE_SUCCESS = Symbol('DELETE_SUCCESS') +const DELETE_FAILURE = Symbol('DELETE_FAILURE') +const PROMOTE_SUCCESS = Symbol('PROMOTE_SUCCESS') +const PROMOTE_FAILURE = Symbol('PROMOTE_FAILURE') + const RegisteredAccounts = () => { const [accounts, setAccounts] = useState([]) const [loading, setLoading] = useState(true) @@ -174,11 +179,6 @@ const PromoteAccountModal = React.memo( ), ) -const DELETE_SUCCESS = Symbol('DELETE_SUCCESS') -const DELETE_FAILURE = Symbol('DELETE_FAILURE') -const PROMOTE_SUCCESS = Symbol('PROMOTE_SUCCESS') -const PROMOTE_FAILURE = Symbol('PROMOTE_FAILURE') - const MESSAGE_RENDERERS = { [DELETE_SUCCESS]: ({ account }, onDismiss) => ( From aaf3b9a3c111ee1602da8f7ebaa7f9f019552fd6 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Mon, 5 Aug 2019 11:32:07 -0700 Subject: [PATCH 06/45] AccountsTable: export default page size --- dev-portal/src/components/Admin/Accounts/AccountsTable.jsx | 4 +++- .../src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx b/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx index 08701e9ec..dad722dec 100644 --- a/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx +++ b/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx @@ -12,6 +12,8 @@ import { import styles from './AccountsTable.module.css' +export const DEFAULT_PAGE_SIZE = 10 + const FILLER_ACCOUNT = Symbol('FILLER_ACCOUNT') const NO_FILTER_COLUMN = Symbol('NO_FILTER_COLUMN') @@ -62,7 +64,7 @@ export const AccountsTable = ({ onSelectAccount, children: toolbarActions, }) => { - const pageSize = 10 + const pageSize = DEFAULT_PAGE_SIZE const [accountsView, setAccountsView] = useState(accounts) const [activePage, setActivePage] = useState(0) diff --git a/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx b/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx index 9bdbb27a5..0056c586d 100644 --- a/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx +++ b/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx @@ -6,6 +6,7 @@ import '@testing-library/jest-dom/extend-expect' import { renderWithRouter } from 'utils/test-utils' import RegisteredAccounts from 'pages/Admin/Accounts/RegisteredAccounts' +import * as AccountsTable from 'components/Admin/Accounts/AccountsTable' import * as AccountService from 'services/accounts' jest.mock('services/accounts') @@ -61,7 +62,7 @@ describe('RegisteredAccounts page', () => { const page = renderPage() await waitForAccountsToLoad(page) - _.range(10).forEach(index => + _.range(AccountsTable.DEFAULT_PAGE_SIZE).forEach(index => expect(page.queryByText(`${index}@example.com`)).not.toBeNull(), ) }) From 7a2172c6e683468cb9f426f010ee8af590c8dd23 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Mon, 5 Aug 2019 11:52:00 -0700 Subject: [PATCH 07/45] RegisteredAccounts: clarify filter/order test expected values --- .../Accounts/__tests__/RegisteredAccounts.jsx | 79 +++++++++++-------- 1 file changed, 44 insertions(+), 35 deletions(-) diff --git a/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx b/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx index 0056c586d..4a3f913d1 100644 --- a/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx +++ b/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx @@ -101,17 +101,19 @@ describe('RegisteredAccounts page', () => { rtl.fireEvent.click(emailAddressHeader) // Check that first page is correct - ;[0, ..._.range(100, 109)] - .map(index => `${index}@example.com`) - .forEach(emailAddress => rtl.getByText(table, emailAddress)) + _(MOCK_ACCOUNTS) + .orderBy(['emailAddress'], ['asc']) + .take(10) + .forEach(({ emailAddress }) => rtl.getByText(table, emailAddress)) // Check that last page is correct const pagination = page.getByRole('navigation') const lastPageButton = rtl.getByLabelText(pagination, 'Last item') rtl.fireEvent.click(lastPageButton) - ;[..._.range(94, 100), 9] - .map(index => `${index}@example.com`) - .forEach(emailAddress => rtl.getByText(table, emailAddress)) + _(MOCK_ACCOUNTS) + .orderBy(['emailAddress'], ['asc']) + .drop(150) + .forEach(({ emailAddress }) => rtl.getByText(table, emailAddress)) // Order descending, go back to first page rtl.fireEvent.click(emailAddressHeader) @@ -119,9 +121,10 @@ describe('RegisteredAccounts page', () => { rtl.fireEvent.click(firstPageButton) // Check that first page is correct - ;[9, ..._.range(99, 90)] - .map(index => `${index}@example.com`) - .forEach(emailAddress => rtl.getByText(table, emailAddress)) + _(MOCK_ACCOUNTS) + .orderBy(['emailAddress'], ['desc']) + .take(10) + .forEach(({ emailAddress }) => rtl.getByText(table, emailAddress)) }) it('orders accounts by date registered', async () => { @@ -138,9 +141,10 @@ describe('RegisteredAccounts page', () => { rtl.fireEvent.click(dateRegisteredHeader) // Check that first page is correct - ;[0, 105, 53, 1, 106, 54, 2, 107, 55, 3] - .map(index => `${index}@example.com`) - .forEach(emailAddress => rtl.getByText(table, emailAddress)) + _(MOCK_ACCOUNTS) + .orderBy('dateRegistered') + .take(10) + .forEach(({ emailAddress }) => rtl.getByText(table, emailAddress)) }) it('filters accounts by email address', async () => { @@ -154,9 +158,10 @@ describe('RegisteredAccounts page', () => { const table = page.getByTestId('accountsTable') rtl.fireEvent.change(filterInput, { target: { value: '11' } }) - ;[11, ..._.range(110, 119)] - .map(index => `${index}@example.com`) - .forEach(emailAddress => rtl.getByText(table, emailAddress)) + _(MOCK_ACCOUNTS) + .filter(({ emailAddress }) => emailAddress.includes('11')) + .take(10) + .forEach(({ emailAddress }) => rtl.getByText(table, emailAddress)) rtl.fireEvent.change(filterInput, { target: { value: '111' } }) rtl.getByText(table, '111@example.com') @@ -182,9 +187,10 @@ describe('RegisteredAccounts page', () => { rtl.fireEvent.click(filterByApiKeyIdOption) rtl.fireEvent.change(filterInput, { target: { value: '15' } }) - ;[15, 115, ..._.range(150, 157)] - .map(index => `apiKeyId${index}`) - .forEach(apiKeyId => rtl.getByText(table, apiKeyId)) + _(MOCK_ACCOUNTS) + .filter(({ apiKeyId }) => apiKeyId.includes('15')) + .take(10) + .forEach(({ apiKeyId }) => rtl.getByText(table, apiKeyId)) rtl.fireEvent.change(filterInput, { target: { value: '155' } }) rtl.getByText(table, 'apiKeyId155') @@ -207,9 +213,11 @@ describe('RegisteredAccounts page', () => { rtl.fireEvent.change(filterInput, { target: { value: '13' } }) rtl.fireEvent.click(dateRegisteredHeader) - ;[113, 13, ..._.range(131, 138)] - .map(index => `apiKeyId${index}`) - .forEach(apiKeyId => rtl.getByText(table, apiKeyId)) + _(MOCK_ACCOUNTS) + .filter(({ emailAddress }) => emailAddress.includes('13')) + .orderBy('dateRegistered') + .take(10) + .forEach(({ emailAddress }) => rtl.getByText(table, emailAddress)) }) it('deletes an account', async () => { @@ -366,17 +374,18 @@ describe('RegisteredAccounts page', () => { const NUM_MOCK_ACCOUNTS = 157 // should be prime -const MOCK_ACCOUNTS = (() => { - const now = Date.now() - return Array.from({ length: NUM_MOCK_ACCOUNTS }).map((_value, index) => ({ - identityPoolId: `identityPoolId${index}`, - userPoolId: `userPoolId${index}`, - emailAddress: `${index}@example.com`, - dateRegistered: new Date( - now + ((index * 3) % NUM_MOCK_ACCOUNTS) * 1000, - ).toJSON(), - apiKeyId: `apiKeyId${index}`, - registrationMethod: _.sample(['open', 'invite', 'request']), - isAdmin: index % 20 === 0, - })) -})() +const MOCK_DATES_REGISTERED = (() => + _.range(NUM_MOCK_ACCOUNTS).map(index => { + const now = Date.now() + return new Date(now + ((index * 3) % NUM_MOCK_ACCOUNTS) * 1000) + }))() + +const MOCK_ACCOUNTS = _.range(NUM_MOCK_ACCOUNTS).map(index => ({ + identityPoolId: `identityPoolId${index}`, + userPoolId: `userPoolId${index}`, + emailAddress: `${index}@example.com`, + dateRegistered: MOCK_DATES_REGISTERED[index].toJSON(), + apiKeyId: `apiKeyId${index}`, + registrationMethod: _.sample(['open', 'invite', 'request']), + isAdmin: index % 20 === 0, +})) From 959f21393781964b7dc271d883f612dacfa7f902 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Mon, 5 Aug 2019 11:54:42 -0700 Subject: [PATCH 08/45] Admin: clean up an import statement --- dev-portal/src/pages/Admin/Admin.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-portal/src/pages/Admin/Admin.jsx b/dev-portal/src/pages/Admin/Admin.jsx index 87d80ba04..648fb81c3 100644 --- a/dev-portal/src/pages/Admin/Admin.jsx +++ b/dev-portal/src/pages/Admin/Admin.jsx @@ -2,7 +2,7 @@ import React, { Component } from 'react' import { BrowserRouter as Router } from 'react-router-dom' import { ApiManagement, SideNav } from './' -import { AdminRoute } from './../../' +import { AdminRoute } from 'index' import RegisteredAccounts from 'pages/Admin/Accounts/RegisteredAccounts' import AdminAccounts from 'pages/Admin/Accounts/AdminAccounts' From ab5a860293415f7a4fb88dc83535407fd4a81d12 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Mon, 5 Aug 2019 13:49:35 -0700 Subject: [PATCH 09/45] RegisteredAccounts: magic numbers begone --- .../Accounts/__tests__/RegisteredAccounts.jsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx b/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx index 4a3f913d1..9902fd81d 100644 --- a/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx +++ b/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx @@ -103,7 +103,7 @@ describe('RegisteredAccounts page', () => { // Check that first page is correct _(MOCK_ACCOUNTS) .orderBy(['emailAddress'], ['asc']) - .take(10) + .take(AccountsTable.DEFAULT_PAGE_SIZE) .forEach(({ emailAddress }) => rtl.getByText(table, emailAddress)) // Check that last page is correct @@ -112,7 +112,10 @@ describe('RegisteredAccounts page', () => { rtl.fireEvent.click(lastPageButton) _(MOCK_ACCOUNTS) .orderBy(['emailAddress'], ['asc']) - .drop(150) + .drop( + Math.floor(NUM_MOCK_ACCOUNTS / AccountsTable.DEFAULT_PAGE_SIZE) * + AccountsTable.DEFAULT_PAGE_SIZE, + ) .forEach(({ emailAddress }) => rtl.getByText(table, emailAddress)) // Order descending, go back to first page @@ -123,7 +126,7 @@ describe('RegisteredAccounts page', () => { // Check that first page is correct _(MOCK_ACCOUNTS) .orderBy(['emailAddress'], ['desc']) - .take(10) + .take(AccountsTable.DEFAULT_PAGE_SIZE) .forEach(({ emailAddress }) => rtl.getByText(table, emailAddress)) }) @@ -143,7 +146,7 @@ describe('RegisteredAccounts page', () => { // Check that first page is correct _(MOCK_ACCOUNTS) .orderBy('dateRegistered') - .take(10) + .take(AccountsTable.DEFAULT_PAGE_SIZE) .forEach(({ emailAddress }) => rtl.getByText(table, emailAddress)) }) @@ -160,7 +163,7 @@ describe('RegisteredAccounts page', () => { rtl.fireEvent.change(filterInput, { target: { value: '11' } }) _(MOCK_ACCOUNTS) .filter(({ emailAddress }) => emailAddress.includes('11')) - .take(10) + .take(AccountsTable.DEFAULT_PAGE_SIZE) .forEach(({ emailAddress }) => rtl.getByText(table, emailAddress)) rtl.fireEvent.change(filterInput, { target: { value: '111' } }) @@ -189,7 +192,7 @@ describe('RegisteredAccounts page', () => { rtl.fireEvent.change(filterInput, { target: { value: '15' } }) _(MOCK_ACCOUNTS) .filter(({ apiKeyId }) => apiKeyId.includes('15')) - .take(10) + .take(AccountsTable.DEFAULT_PAGE_SIZE) .forEach(({ apiKeyId }) => rtl.getByText(table, apiKeyId)) rtl.fireEvent.change(filterInput, { target: { value: '155' } }) @@ -216,7 +219,7 @@ describe('RegisteredAccounts page', () => { _(MOCK_ACCOUNTS) .filter(({ emailAddress }) => emailAddress.includes('13')) .orderBy('dateRegistered') - .take(10) + .take(AccountsTable.DEFAULT_PAGE_SIZE) .forEach(({ emailAddress }) => rtl.getByText(table, emailAddress)) }) From 594f375e59ebfe59060dbefc2dae358a82742247 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Mon, 5 Aug 2019 14:46:56 -0700 Subject: [PATCH 10/45] RegisteredAccounts: typo --- .../Admin/Accounts/__tests__/RegisteredAccounts.jsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx b/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx index 9902fd81d..9941d038f 100644 --- a/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx +++ b/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx @@ -377,11 +377,12 @@ describe('RegisteredAccounts page', () => { const NUM_MOCK_ACCOUNTS = 157 // should be prime -const MOCK_DATES_REGISTERED = (() => - _.range(NUM_MOCK_ACCOUNTS).map(index => { - const now = Date.now() - return new Date(now + ((index * 3) % NUM_MOCK_ACCOUNTS) * 1000) - }))() +const MOCK_DATES_REGISTERED = (() => { + const now = Date.now() + return _.range(NUM_MOCK_ACCOUNTS).map( + index => new Date(now + ((index * 3) % NUM_MOCK_ACCOUNTS) * 1000), + ) +})() const MOCK_ACCOUNTS = _.range(NUM_MOCK_ACCOUNTS).map(index => ({ identityPoolId: `identityPoolId${index}`, From bcca9025a6b18349cee3fa46d2a7e49950e742f4 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Tue, 6 Aug 2019 17:24:15 -0700 Subject: [PATCH 11/45] AccountsTable: fix redundant/missing hook dependencies --- .../src/components/Admin/Accounts/AccountsTable.jsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx b/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx index dad722dec..01a026f38 100644 --- a/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx +++ b/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx @@ -97,13 +97,7 @@ export const AccountsTable = ({ else if (!filterableColumns.includes(filterColumn)) { setFilterColumn(NO_FILTER_COLUMN) } - }, [ - columns, - filterColumn, - setFilterColumn, - setFilterValue, - setFilterableColumns, - ]) + }, [columns, filterColumn]) /** * Sets `accountsView` to the filtered and sorted subset of `props.accounts`. @@ -140,7 +134,7 @@ export const AccountsTable = ({ } return pageItems }, - [accountsView], + [accountsView, pageSize], ) const totalPages = Math.ceil(accountsView.length / pageSize) From c6542024a59310764319a81b18edecb4175474aa Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Thu, 8 Aug 2019 13:34:47 -0700 Subject: [PATCH 12/45] AccountsTable: simplify filter/order state hooks --- .../Admin/Accounts/AccountsTable.jsx | 151 +++++++++--------- 1 file changed, 75 insertions(+), 76 deletions(-) diff --git a/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx b/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx index 01a026f38..98a19f4b7 100644 --- a/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx +++ b/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx @@ -73,10 +73,14 @@ export const AccountsTable = ({ ) const [filterableColumns, setFilterableColumns] = useState([]) - const [filterColumn, setFilterColumn] = useState(NO_FILTER_COLUMN) - const [filterValue, setFilterValue] = useState(NO_FILTER_VALUE) - const [orderColumn, setOrderColumn] = useState(NO_ORDER_COLUMN) - const [orderDirectionIndex, setOrderDirectionIndex] = useState(0) + const [filter, setFilter] = useState({ + column: NO_FILTER_COLUMN, + value: NO_FILTER_VALUE, + }) + const [order, setOrder] = useState({ + column: NO_ORDER_COLUMN, + directionIndex: 0, + }) useEffect(() => { const filterableColumns = columns.filter(column => column.filtering) @@ -84,41 +88,44 @@ export const AccountsTable = ({ // Reset filtering state if no columns are filterable if (filterableColumns.length === 0) { - setFilterColumn(NO_FILTER_COLUMN) - setFilterValue(NO_FILTER_VALUE) + setFilter({ + column: NO_FILTER_COLUMN, + value: NO_FILTER_VALUE, + }) } // Pick the first filterable column if one is available - else if (filterColumn === NO_FILTER_COLUMN) { - setFilterColumn(filterableColumns[0]) + else if (filter.column === NO_FILTER_COLUMN) { + setFilter(filter => ({ ...filter, column: filterableColumns[0] })) } // Reset filterColumn if it's no longer among the available columns - else if (!filterableColumns.includes(filterColumn)) { - setFilterColumn(NO_FILTER_COLUMN) + else if (!filterableColumns.includes(filter.column)) { + setFilter(filter => ({ ...filter, column: NO_FILTER_COLUMN })) } - }, [columns, filterColumn]) + }, [columns, filter]) /** * Sets `accountsView` to the filtered and sorted subset of `props.accounts`. */ useEffect(() => { let view = _(accounts) - if (filterColumn !== NO_FILTER_COLUMN) { - const filterKey = filterColumn.filtering.accessor + if (filter.column !== NO_FILTER_COLUMN) { + const filterKey = filter.column.filtering.accessor view = view.filter( item => - !!item[filterKey] && item[filterKey].toString().includes(filterValue), + !!item[filterKey] && + item[filterKey].toString().includes(filter.value), ) } - if (orderColumn !== NO_ORDER_COLUMN) { + if (order.column !== NO_ORDER_COLUMN) { view = view.orderBy( - [orderColumn.ordering.iteratee], - [ORDER_DIRECTIONS[orderDirectionIndex].lodashDirection], + [order.column.ordering.iteratee], + [ORDER_DIRECTIONS[order.directionIndex].lodashDirection], ) } setAccountsView(view.value()) - }, [accounts, filterColumn, filterValue, orderColumn, orderDirectionIndex]) + }, [accounts, filter, order]) /** * Returns a page of accounts from `accountView` according to the given page @@ -178,10 +185,14 @@ export const AccountsTable = ({ ) const onFilterColumnDropdownChange = (_event, { value }) => - setFilterColumn( - filterableColumns.find(column => column.id === value) || NO_FILTER_COLUMN, - ) - const onSearchInputChange = (_event, { value }) => setFilterValue(value) + setFilter(filter => ({ + ...filter, + column: + filterableColumns.find(column => column.id === value) || + NO_FILTER_COLUMN, + })) + const onSearchInputChange = (_event, { value }) => + setFilter(filter => ({ ...filter, value })) const toolbar = ( <> @@ -193,7 +204,7 @@ export const AccountsTable = ({ iconPosition='left' icon='search' placeholder='Search by...' - value={filterValue} + value={filter.value} onChange={onSearchInputChange} style={{ maxWidth: '24em' }} /> @@ -206,7 +217,7 @@ export const AccountsTable = ({ onChange={onFilterColumnDropdownChange} options={filterColumnDropdownOptions} selection - value={filterColumn.id} + value={filter.column.id} data-testid='filterDropdown' /> @@ -218,13 +229,7 @@ export const AccountsTable = ({ const table = ( - + {tableRows} @@ -251,54 +256,48 @@ export const AccountsTable = ({ ) } -const TableHeader = React.memo( - ({ - columns, - orderColumn, - setOrderColumn, - orderDirectionIndex, - setOrderDirectionIndex, - }) => { - // Clicking on a column makes it the "orderColumn". If that column was - // already the "orderColumn", cycle between order directions (none, - // ascending, descending). Otherwise, start at the beginning of the cycle - // (ascending). - const onToggleOrder = column => () => { - if (column === orderColumn) { - const nextIndex = nextDirectionIndex(orderDirectionIndex) - if (nextIndex === 0) { - setOrderColumn(NO_ORDER_COLUMN) - } - setOrderDirectionIndex(nextIndex) - } else { - setOrderColumn(column) - setOrderDirectionIndex(nextDirectionIndex(0)) +const TableHeader = React.memo(({ columns, order, setOrder }) => { + // Clicking on a column makes it the "order column". If that column was + // already the "order column", cycle between order directions (none, + // ascending, descending). Otherwise, start at the beginning of the cycle + // (ascending). + const onToggleOrder = column => () => { + const nextOrder = { ...order } + + if (column === order.column) { + const nextIndex = nextDirectionIndex(order.directionIndex) + if (nextIndex === 0) { + nextOrder.column = NO_ORDER_COLUMN } + nextOrder.directionIndex = nextIndex + } else { + nextOrder.column = column + nextOrder.directionIndex = nextDirectionIndex(0) } - const orderDirection = ORDER_DIRECTIONS[orderDirectionIndex] - return ( - - - {columns.map((column, index) => ( - - {column.title} - {column === orderColumn && ( - - )} - {column.ordering && column !== orderColumn && ( - - )} - - ))} - - - ) - }, -) + setOrder(nextOrder) + } + + const orderDirection = ORDER_DIRECTIONS[order.directionIndex] + return ( + + + {columns.map((column, index) => ( + + {column.title} + {column === order.column && } + {column.ordering && column !== order.column && ( + + )} + + ))} + + + ) +}) const LoadingAccountRow = React.memo(({ columnCount }) => ( From f68c4540ea7926b952c3dc65397157efa9233721 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Thu, 8 Aug 2019 14:13:09 -0700 Subject: [PATCH 13/45] MessageList: simplify usage --- dev-portal/src/components/MessageList.jsx | 50 +++++------ .../Admin/Accounts/RegisteredAccounts.jsx | 85 +++++++------------ 2 files changed, 52 insertions(+), 83 deletions(-) diff --git a/dev-portal/src/components/MessageList.jsx b/dev-portal/src/components/MessageList.jsx index 2500ca2ac..bbcacb20b 100644 --- a/dev-portal/src/components/MessageList.jsx +++ b/dev-portal/src/components/MessageList.jsx @@ -1,35 +1,29 @@ import React, { useState } from 'react' -export const MessageList = ({ messages, dismissMessage, renderers }) => ( - <> - {messages.map((message, index) => { - const { type, ...payload } = message - if (renderers[type]) { - return ( - - {renderers[type](payload, () => dismissMessage(message))} - - ) - } - throw new Error(`Unknown message type: ${type.toString()}`) - })} - -) +export const MessageList = ({ messages }) => + messages.map((message, index) => ( + {message} + )) -export const useMessageQueue = initialMessages => { - const [messages, setMessages] = useState(initialMessages || []) +/** + * A Hook for operating a list of "messages" which should be self-dismissable. + * Returns `[messages, sendMessage]`, where: + * - `messages` is an array of renderable messages (of type `React.ReactNode`) + * - `sendMessage` is a function which accepts a renderer callback, and + * calls the callback to obtain a renderable message to append to + * `messages`. The renderer callback should accept a `dismiss` function as + * its sole argument, which removes the renderable message from `messages` + * when called. + */ +export const useMessages = () => { + const [messages, setMessages] = useState([]) - const sendMessage = target => setMessages([...messages, target]) - const dismissMessage = target => { - const deleteIndex = messages.findIndex(message => message === target) - if (deleteIndex === -1) { - throw new Error('Message not found') - } - setMessages([ - ...messages.slice(0, deleteIndex), - ...messages.slice(deleteIndex + 1), - ]) + const sendMessage = renderWithDismiss => { + const target = renderWithDismiss(() => { + setMessages(messages => messages.filter(message => message !== target)) + }) + setMessages(messages => [...messages, target]) } - return [messages, sendMessage, dismissMessage] + return [messages, sendMessage] } diff --git a/dev-portal/src/pages/Admin/Accounts/RegisteredAccounts.jsx b/dev-portal/src/pages/Admin/Accounts/RegisteredAccounts.jsx index c2de56a99..7f4d290c5 100644 --- a/dev-portal/src/pages/Admin/Accounts/RegisteredAccounts.jsx +++ b/dev-portal/src/pages/Admin/Accounts/RegisteredAccounts.jsx @@ -6,18 +6,13 @@ import * as AccountService from 'services/accounts' import * as AccountsTable from 'components/Admin/Accounts/AccountsTable' import * as AccountsTableColumns from 'components/Admin/Accounts/AccountsTableColumns' -const DELETE_SUCCESS = Symbol('DELETE_SUCCESS') -const DELETE_FAILURE = Symbol('DELETE_FAILURE') -const PROMOTE_SUCCESS = Symbol('PROMOTE_SUCCESS') -const PROMOTE_FAILURE = Symbol('PROMOTE_FAILURE') - const RegisteredAccounts = () => { const [accounts, setAccounts] = useState([]) const [loading, setLoading] = useState(true) const [selectedAccount, setSelectedAccount] = useState(undefined) const [deleteModalOpen, setDeleteModalOpen] = useState(false) const [promoteModalOpen, setPromoteModalOpen] = useState(false) - const [messages, sendMessage, dismissMessage] = MessageList.useMessageQueue() + const [messages, sendMessage] = MessageList.useMessages() const refreshAccounts = () => AccountService.fetchRegisteredAccounts().then(accounts => @@ -40,14 +35,18 @@ const RegisteredAccounts = () => { await AccountService.deleteAccountByIdentityPoolId( selectedAccount.identityPoolId, ) - sendMessage({ type: DELETE_SUCCESS, account: selectedAccount }) + sendMessage(dismiss => ( + + )) await refreshAccounts() } catch (error) { - sendMessage({ - type: DELETE_FAILURE, - account: selectedAccount, - errorMessage: error.message, - }) + sendMessage(dismiss => ( + + )) } finally { setLoading(false) } @@ -60,27 +59,26 @@ const RegisteredAccounts = () => { await AccountService.promoteAccountByIdentityPoolId( selectedAccount.identityPoolId, ) - sendMessage({ type: PROMOTE_SUCCESS, account: selectedAccount }) + sendMessage(dismiss => ( + + )) } catch (error) { - sendMessage({ - type: PROMOTE_FAILURE, - account: selectedAccount, - errorMessage: error.message, - }) + sendMessage(dismiss => ( + + )) } finally { setLoading(false) } }, [sendMessage, selectedAccount]) - return (
Registered accounts
- + ( - - ), - [DELETE_FAILURE]: ({ account, errorMessage }, onDismiss) => ( - - ), - [PROMOTE_SUCCESS]: ({ account }, onDismiss) => ( - - ), - [PROMOTE_FAILURE]: ({ account, errorMessage }, onDismiss) => ( - - ), -} - -const DeleteSuccessMessage = React.memo(({ account, onDismiss }) => ( - +const DeleteSuccessMessage = React.memo(({ account, dismiss }) => ( + Deleted account {account.emailAddress}. @@ -211,8 +186,8 @@ const DeleteSuccessMessage = React.memo(({ account, onDismiss }) => ( )) const DeleteFailureMessage = React.memo( - ({ account, errorMessage, onDismiss }) => ( - + ({ account, errorMessage, dismiss }) => ( +

Failed to delete account {account.emailAddress}. @@ -223,8 +198,8 @@ const DeleteFailureMessage = React.memo( ), ) -const PromoteSuccessMessage = React.memo(({ account, onDismiss }) => ( - +const PromoteSuccessMessage = React.memo(({ account, dismiss }) => ( + Promoted account {account.emailAddress}. @@ -232,8 +207,8 @@ const PromoteSuccessMessage = React.memo(({ account, onDismiss }) => ( )) const PromoteFailureMessage = React.memo( - ({ account, errorMessage, onDismiss }) => ( - + ({ account, errorMessage, dismiss }) => ( +

Failed to promote account {account.emailAddress}. From 768fba550b4965d7827842f1b51467653607f7f1 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Thu, 1 Aug 2019 14:17:15 -0700 Subject: [PATCH 14/45] Move React 16.8 act() warning suppression into test-utils --- .../Accounts/__tests__/RegisteredAccounts.jsx | 24 ++++--------------- dev-portal/src/utils/test-utils.jsx | 21 ++++++++++++++++ 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx b/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx index 9941d038f..e1644def9 100644 --- a/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx +++ b/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx @@ -3,7 +3,7 @@ import React from 'react' import * as rtl from '@testing-library/react' import '@testing-library/jest-dom/extend-expect' -import { renderWithRouter } from 'utils/test-utils' +import * as testUtils from 'utils/test-utils' import RegisteredAccounts from 'pages/Admin/Accounts/RegisteredAccounts' import * as AccountsTable from 'components/Admin/Accounts/AccountsTable' @@ -11,28 +11,12 @@ import * as AccountService from 'services/accounts' jest.mock('services/accounts') -/** - * Suppress React 16.8 act() warnings globally. - * The React team's fix won't be out of alpha until 16.9.0. - * - * See - */ -const consoleError = console.error -beforeAll(() => { - jest.spyOn(console, 'error').mockImplementation((...args) => { - if ( - !args[0].includes( - 'Warning: An update to %s inside a test was not wrapped in act', - ) - ) { - consoleError(...args) - } - }) -}) +// TODO: remove when React 16.9 is released +testUtils.suppressReact16Dot8ActWarningsGlobally() afterEach(rtl.cleanup) -const renderPage = () => renderWithRouter() +const renderPage = () => testUtils.renderWithRouter() const waitForAccountsToLoad = page => rtl.waitForElementToBeRemoved(() => diff --git a/dev-portal/src/utils/test-utils.jsx b/dev-portal/src/utils/test-utils.jsx index b268aef89..26e9c6742 100644 --- a/dev-portal/src/utils/test-utils.jsx +++ b/dev-portal/src/utils/test-utils.jsx @@ -29,3 +29,24 @@ export const renderWithRouter = ( */ export const resolveAfter = (ms, resolution) => new Promise(resolve => setTimeout(() => resolve(resolution), ms)) + +/** + * Suppress React 16.8 act() warnings globally. + * The React team's fix won't be out of alpha until 16.9.0. + * + * See + */ +export const suppressReact16Dot8ActWarningsGlobally = () => { + const consoleError = console.error + beforeAll(() => { + jest.spyOn(console, 'error').mockImplementation((...args) => { + if ( + !args[0].includes( + 'Warning: An update to %s inside a test was not wrapped in act', + ) + ) { + consoleError(...args) + } + }) + }) +} From ff3dae329dc731ff5570de07898db24400042321 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Thu, 1 Aug 2019 16:56:28 -0700 Subject: [PATCH 15/45] Add AdminAccounts page --- .../Admin/Accounts/AccountsTable.jsx | 31 ++- .../Admin/Accounts/AccountsTableColumns.jsx | 51 +++- .../pages/Admin/Accounts/AdminAccounts.jsx | 47 +++- .../Accounts/__tests__/AdminAccounts.jsx | 248 ++++++++++++++++++ dev-portal/src/services/accounts.js | 59 +++-- 5 files changed, 391 insertions(+), 45 deletions(-) create mode 100644 dev-portal/src/pages/Admin/Accounts/__tests__/AdminAccounts.jsx diff --git a/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx b/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx index 98a19f4b7..37df37a24 100644 --- a/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx +++ b/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx @@ -106,18 +106,27 @@ export const AccountsTable = ({ }, [columns, filter]) /** - * Sets `accountsView` to the filtered and sorted subset of `props.accounts`. + * Sets `accountsView` to the filtered subset of `props.accounts`. */ useEffect(() => { let view = _(accounts) - if (filter.column !== NO_FILTER_COLUMN) { - const filterKey = filter.column.filtering.accessor - view = view.filter( - item => - !!item[filterKey] && - item[filterKey].toString().includes(filter.value), - ) + if (filter.value !== '' && filter.column !== NO_FILTER_COLUMN) { + const filterAccessor = filter.column.filtering.accessor + if (typeof filterAccessor === 'string') { + view = view.filter( + item => + !!item[filterAccessor] && + item[filterAccessor].toString().includes(filter.value), + ) + } else if (typeof filterAccessor === 'function') { + view = view.filter(item => filterAccessor(item).includes(filter.value)) + } else { + throw new Error( + `Invalid filtering accessor on column ${filter.column.id}`, + ) + } } + if (order.column !== NO_ORDER_COLUMN) { view = view.orderBy( [order.column.ordering.iteratee], @@ -322,8 +331,10 @@ const FillerAccountRow = React.memo(({ columnCount }) => ( const AccountRow = React.memo(({ account, columns, isSelected, onSelect }) => { return ( onSelect(account)}> - {columns.map(({ render }, index) => ( - {render(account)} + {columns.map(({ id, render }, index) => ( + + {render(account)} + ))} ) diff --git a/dev-portal/src/components/Admin/Accounts/AccountsTableColumns.jsx b/dev-portal/src/components/Admin/Accounts/AccountsTableColumns.jsx index 84cca5030..24fd610bb 100644 --- a/dev-portal/src/components/Admin/Accounts/AccountsTableColumns.jsx +++ b/dev-portal/src/components/Admin/Accounts/AccountsTableColumns.jsx @@ -18,7 +18,8 @@ * filtering descriptor for this column. If absent, the user cannot filter * on this column. * @property {string} filtering.accessor - * an Account object property name on which to search + * either an Account object property name, or a function which takes an + * Account object and returns a string, on which to filter */ export const EmailAddress = { @@ -33,18 +34,6 @@ export const EmailAddress = { }, } -const DATE_TIME_FORMATTER = new Intl.DateTimeFormat('default', { - year: 'numeric', - month: 'numeric', - day: 'numeric', - hour: 'numeric', - minute: 'numeric', - second: 'numeric', -}) - -const formatDate = isoDateString => - DATE_TIME_FORMATTER.format(new Date(isoDateString)) - export const DateRegistered = { id: 'dateRegistered', title: 'Date registered', @@ -68,3 +57,39 @@ export const ApiKeyId = { accessor: 'apiKeyId', }, } + +export const Promoter = { + id: 'promoter', + title: 'Promoter', + render: ({ promoterIdentityPoolId, promoterEmailAddress }) => + promoterIdentityPoolId + ? `${promoterEmailAddress} (${promoterIdentityPoolId})` + : '', + filtering: { + accessor: ({ promoterIdentityPoolId, promoterEmailAddress }) => + promoterIdentityPoolId + ? `${promoterEmailAddress} ${promoterIdentityPoolId}` + : '', + }, +} + +export const DatePromoted = { + id: 'datePromoted', + title: 'Date promoted', + render: ({ datePromoted }) => (datePromoted ? formatDate(datePromoted) : ''), + ordering: { + iteratee: 'datePromoted', + }, +} + +const DATE_TIME_FORMATTER = new Intl.DateTimeFormat('default', { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', +}) + +const formatDate = isoDateString => + DATE_TIME_FORMATTER.format(new Date(isoDateString)) diff --git a/dev-portal/src/pages/Admin/Accounts/AdminAccounts.jsx b/dev-portal/src/pages/Admin/Accounts/AdminAccounts.jsx index bde963471..37ee80120 100644 --- a/dev-portal/src/pages/Admin/Accounts/AdminAccounts.jsx +++ b/dev-portal/src/pages/Admin/Accounts/AdminAccounts.jsx @@ -1,7 +1,44 @@ -import React, { Component } from 'react' +import React, { useCallback, useEffect, useState } from 'react' +import { Container, Header } from 'semantic-ui-react' -export default class AdminAccounts extends Component { - render = () => { - return

TODO: Admin accounts

- } +import * as AccountService from 'services/accounts' +import * as AccountsTable from 'components/Admin/Accounts/AccountsTable' +import * as AccountsTableColumns from 'components/Admin/Accounts/AccountsTableColumns' + +const AdminAccounts = () => { + const [accounts, setAccounts] = useState([]) + const [loading, setLoading] = useState(true) + const [selectedAccount, setSelectedAccount] = useState(undefined) + + const refreshAccounts = () => + AccountService.fetchAdminAccounts().then(accounts => + setAccounts(accounts), + ) + + // Initial load + useEffect(() => { + refreshAccounts().finally(() => setLoading(false)) + }, []) + + const onSelectAccount = useCallback(account => setSelectedAccount(account), [ + setSelectedAccount, + ]) + + return ( + +
Admin accounts
+ +
+ ) } +export default AdminAccounts diff --git a/dev-portal/src/pages/Admin/Accounts/__tests__/AdminAccounts.jsx b/dev-portal/src/pages/Admin/Accounts/__tests__/AdminAccounts.jsx new file mode 100644 index 000000000..2807e6904 --- /dev/null +++ b/dev-portal/src/pages/Admin/Accounts/__tests__/AdminAccounts.jsx @@ -0,0 +1,248 @@ +import _ from 'lodash' +import React from 'react' +import * as rtl from '@testing-library/react' +import '@testing-library/jest-dom/extend-expect' + +import * as testUtils from 'utils/test-utils' + +import AdminAccounts from 'pages/Admin/Accounts/AdminAccounts' +import * as AccountService from 'services/accounts' + +jest.mock('services/accounts') + +// TODO: remove when React 16.9 is released +testUtils.suppressReact16Dot8ActWarningsGlobally() + +afterEach(rtl.cleanup) + +const renderPage = () => testUtils.renderWithRouter() + +const waitForAccountsToLoad = page => + rtl.waitForElementToBeRemoved(() => + page.queryAllByTestId('accountRowPlaceholder'), + ) + +const queryAllByColumnText = (container, columnId, text) => + rtl + .queryAllByText(container, text) + .filter(el => el.getAttribute('data-account-column-id') === columnId) + +const queryByColumnText = (container, columnId, text) => + _.get(queryAllByColumnText(container, columnId, text), [0], null) + +const expectEmailIn = (email, container) => + expect(queryByColumnText(container, 'emailAddress', email)).not.toBeNull() + +describe('AdminAccounts page', () => { + it('renders', async () => { + AccountService.fetchAdminAccounts = jest.fn().mockResolvedValue([]) + const page = renderPage() + expect(page.baseElement).toBeTruthy() + }) + + it('initially shows the loading state', async () => { + AccountService.fetchAdminAccounts = jest + .fn() + .mockReturnValue(new Promise(() => {})) + + const page = renderPage() + expect(page.queryAllByTestId('accountRowPlaceholder')).not.toHaveLength(0) + }) + + it('shows the accounts after loading', async () => { + AccountService.fetchAdminAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ADMINS) + const page = renderPage() + await waitForAccountsToLoad(page) + + _.take(_.range(0, NUM_MOCK_ACCOUNTS, 10), 10).forEach(index => + expectEmailIn(`${index}@example.com`, page.baseElement), + ) + }) + + it('orders pages for all accounts', async () => { + AccountService.fetchAdminAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ADMINS) + + const page = renderPage() + await waitForAccountsToLoad(page) + const pagination = page.getByRole('navigation') + + const page1Button = rtl.queryByText(pagination, '1') + expect(page1Button).not.toBeNull() + + const page2Button = rtl.queryByText(pagination, '2') + expect(page2Button).not.toBeNull() + rtl.fireEvent.click(page2Button) + expectEmailIn('150@example.com', page.baseElement) + }) + + it('orders accounts by email address', async () => { + AccountService.fetchAdminAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ADMINS) + + const page = renderPage() + await waitForAccountsToLoad(page) + + // Order ascending + const table = page.getByTestId('accountsTable') + const emailAddressHeader = rtl.getByText(table, 'Email address') + rtl.fireEvent.click(emailAddressHeader) + + // Check that first page is correct + ;[0, 100, 10, 110, 120, 130, 140, 150, 20, 30] + .map(index => `${index}@example.com`) + .forEach(emailAddress => expectEmailIn(emailAddress, table)) + + // Check that last page is correct + const pagination = page.getByRole('navigation') + const lastPageButton = rtl.getByLabelText(pagination, 'Last item') + rtl.fireEvent.click(lastPageButton) + ;[40, 50, 60, 70, 80, 90] + .map(index => `${index}@example.com`) + .forEach(emailAddress => expectEmailIn(emailAddress, table)) + + // Order descending, go back to first page + rtl.fireEvent.click(emailAddressHeader) + const firstPageButton = rtl.getByLabelText(pagination, 'First item') + rtl.fireEvent.click(firstPageButton) + + // Check that first page is correct + ;[90, 80, 70, 60, 50, 40, 30, 20, 150, 140] + .map(index => `${index}@example.com`) + .forEach(emailAddress => expectEmailIn(emailAddress, table)) + }) + + it('orders accounts by date promoted', async () => { + AccountService.fetchAdminAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ADMINS) + + const page = renderPage() + await waitForAccountsToLoad(page) + + // Order ascending + const table = page.getByTestId('accountsTable') + const dateRegisteredHeader = rtl.getByText(table, 'Date promoted') + rtl.fireEvent.click(dateRegisteredHeader) + + // Check that first page is correct + ;[110, 60, 120, 70, 20, 130, 80, 30, 140, 90] + .map(index => `${index}@example.com`) + .forEach(emailAddress => expectEmailIn(emailAddress, table)) + }) + + it('filters accounts by email address', async () => { + AccountService.fetchAdminAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ADMINS) + + const page = renderPage() + await waitForAccountsToLoad(page) + const filterInput = page.getByPlaceholderText('Search by...') + const table = page.getByTestId('accountsTable') + + rtl.fireEvent.change(filterInput, { target: { value: '1' } }) + ;[110, 120, 130, 140, 150, 100, 10] + .map(index => `${index}@example.com`) + .forEach(emailAddress => expectEmailIn(emailAddress, table)) + + rtl.fireEvent.change(filterInput, { target: { value: '9' } }) + rtl.getByText(table, '90@example.com') + expect( + queryAllByColumnText(table, 'emailAddress', /@example\.com/), + ).toHaveLength(1) + }) + + it('filters accounts by promoter email address', async () => { + AccountService.fetchAdminAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ADMINS) + + const page = renderPage() + await waitForAccountsToLoad(page) + const filterInput = page.getByPlaceholderText('Search by...') + const filterDropdown = page.getByTestId('filterDropdown') + const table = page.getByTestId('accountsTable') + + rtl.fireEvent.click(filterDropdown) + const filterByApiKeyIdOption = rtl.getByText(filterDropdown, 'Promoter') + rtl.fireEvent.click(filterByApiKeyIdOption) + + rtl.fireEvent.change(filterInput, { target: { value: '20@example.com' } }) + ;[90, 100, 110] + .map(index => `${index}@example.com`) + .forEach(emailAddress => expectEmailIn(emailAddress, table)) + expect( + queryAllByColumnText(table, 'emailAddress', /@example\.com/), + ).toHaveLength(3) + + rtl.fireEvent.change(filterInput, { target: { value: '30@example.com' } }) + expect( + queryAllByColumnText(table, 'emailAddress', /@example\.com/), + ).toHaveLength(0) + }) + + it('filters accounts by promoter identity pool ID', async () => { + AccountService.fetchAdminAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ADMINS) + + const page = renderPage() + await waitForAccountsToLoad(page) + const filterInput = page.getByPlaceholderText('Search by...') + const filterDropdown = page.getByTestId('filterDropdown') + const table = page.getByTestId('accountsTable') + + rtl.fireEvent.click(filterDropdown) + const filterByApiKeyIdOption = rtl.getByText(filterDropdown, 'Promoter') + rtl.fireEvent.click(filterByApiKeyIdOption) + + rtl.fireEvent.change(filterInput, { target: { value: 'identityPoolId20' } }) + ;[90, 100, 110] + .map(index => `${index}@example.com`) + .forEach(emailAddress => expectEmailIn(emailAddress, table)) + expect( + queryAllByColumnText(table, 'emailAddress', /@example\.com/), + ).toHaveLength(3) + + rtl.fireEvent.change(filterInput, { target: { value: 'identityPoolId30' } }) + expect( + queryAllByColumnText(table, 'emailAddress', /@example\.com/), + ).toHaveLength(0) + }) +}) + +const NUM_MOCK_ACCOUNTS = 157 // should be prime + +const MOCK_ACCOUNTS = (() => { + const now = Date.now() + const adminStep = 10 + return Array.from({ length: NUM_MOCK_ACCOUNTS }).map((_value, index) => { + let promoter = null + if (_.inRange(index, 20, 90)) { + promoter = 10 + } else if (_.inRange(index, 90, 120)) { + promoter = 20 + } else if (_.inRange(index, 120, NUM_MOCK_ACCOUNTS)) { + promoter = 100 + } + + return { + identityPoolId: `identityPoolId${index}`, + userPoolId: `userPoolId${index}`, + emailAddress: `${index}@example.com`, + datePromoted: + promoter && + new Date(now + ((index * 3) % NUM_MOCK_ACCOUNTS) * 1000).toJSON(), + promoterEmailAddress: promoter && `${promoter}@example.com`, + promoterIdentityPoolId: promoter && `identityPoolId${promoter}`, + isAdmin: index % adminStep === 0, + } + }) +})() + +const MOCK_ADMINS = () => MOCK_ACCOUNTS.filter(account => account.isAdmin) diff --git a/dev-portal/src/services/accounts.js b/dev-portal/src/services/accounts.js index bdccf8625..20e913316 100644 --- a/dev-portal/src/services/accounts.js +++ b/dev-portal/src/services/accounts.js @@ -2,30 +2,53 @@ import _ from 'lodash' import { resolveAfter } from 'utils/test-utils' -const now = Date.now() -const numMockAccounts = 157 // should be prime -const mockData = Array.from({ length: numMockAccounts }).map( - (_value, index) => ({ - identityPoolId: `identityPoolId${index}`, - userPoolId: `userPoolId${index}`, - emailAddress: `${index}@example.com`, - dateRegistered: new Date( - now + ((index * 3) % numMockAccounts) * 1000, - ).toJSON(), - apiKeyId: `apiKeyId${index}`, - registrationMethod: _.sample(['open', 'invite', 'request']), - isAdmin: index % 20 === 0, - }), -) +const NUM_MOCK_ACCOUNTS = 157 // should be prime +const mockData = (() => { + const now = Date.now() + const adminStep = 10 + return Array.from({ length: NUM_MOCK_ACCOUNTS }).map((_value, index) => { + let promoter = null + if (_.inRange(index, 20, 90)) { + promoter = 10 + } else if (_.inRange(index, 90, 120)) { + promoter = 20 + } else if (_.inRange(index, 120, NUM_MOCK_ACCOUNTS)) { + promoter = 100 + } + + return { + identityPoolId: `identityPoolId${index}`, + userPoolId: `userPoolId${index}`, + emailAddress: `${index}@example.com`, + datePromoted: + promoter && + new Date(now + ((index * 3) % NUM_MOCK_ACCOUNTS) * 1000).toJSON(), + promoterEmailAddress: promoter && `${promoter}@example.com`, + promoterIdentityPoolId: promoter && `identityPoolId${promoter}`, + dateRegistered: new Date( + now + ((index * 3) % NUM_MOCK_ACCOUNTS) * 1000, + ).toJSON(), + apiKeyId: `apiKeyId${index}`, + registrationMethod: _.sample(['open', 'invite', 'request']), + isAdmin: index % adminStep === 0, + } + }) +})() export const fetchRegisteredAccounts = () => { return resolveAfter(1500, mockData.slice()) } +export const fetchAdminAccounts = () => { + return resolveAfter(1500, mockData.filter(account => account.isAdmin)) +} + export const deleteAccountByIdentityPoolId = async identityPoolId => { await resolveAfter(1500) - const accountIndex = mockData.findIndex(account => account.identityPoolId === identityPoolId) + const accountIndex = mockData.findIndex( + account => account.identityPoolId === identityPoolId, + ) if (accountIndex === -1) { throw new Error('Account not found!') } @@ -38,7 +61,9 @@ export const deleteAccountByIdentityPoolId = async identityPoolId => { export const promoteAccountByIdentityPoolId = async identityPoolId => { await resolveAfter(1500) - const account = mockData.find(account => account.identityPoolId === identityPoolId) + const account = mockData.find( + account => account.identityPoolId === identityPoolId, + ) if (account === undefined) { throw new Error('Account not found!') } From 5d40fd8edbff0777e4631e9d88fce540c7df6933 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Mon, 5 Aug 2019 13:44:51 -0700 Subject: [PATCH 16/45] AdminAccounts: extract Accounts-related test utils to own file --- .../Admin/Accounts/AccountsTable.jsx | 7 +- .../Accounts/__tests__/AdminAccounts.jsx | 93 +++++++++++-------- dev-portal/src/utils/AccountsTestUtils.jsx | 26 ++++++ 3 files changed, 87 insertions(+), 39 deletions(-) create mode 100644 dev-portal/src/utils/AccountsTestUtils.jsx diff --git a/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx b/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx index 37df37a24..5c0f2c3da 100644 --- a/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx +++ b/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx @@ -37,6 +37,9 @@ const ORDER_DIRECTIONS = [ const nextDirectionIndex = index => (index + 1) % ORDER_DIRECTIONS.length +export const ACCOUNT_ROW_PLACEHOLDER_TESTID = 'accountRowPlaceholder' +export const ACCOUNT_COLUMN_ID_DATA_ATTR = 'data-account-column-id' + /** * A paginated table whose rows represent accounts. * @@ -312,7 +315,7 @@ const LoadingAccountRow = React.memo(({ columnCount }) => ( {Array.from({ length: columnCount }).map((_value, index) => ( - +   @@ -332,7 +335,7 @@ const AccountRow = React.memo(({ account, columns, isSelected, onSelect }) => { return ( onSelect(account)}> {columns.map(({ id, render }, index) => ( - + {render(account)} ))} diff --git a/dev-portal/src/pages/Admin/Accounts/__tests__/AdminAccounts.jsx b/dev-portal/src/pages/Admin/Accounts/__tests__/AdminAccounts.jsx index 2807e6904..62b6d811c 100644 --- a/dev-portal/src/pages/Admin/Accounts/__tests__/AdminAccounts.jsx +++ b/dev-portal/src/pages/Admin/Accounts/__tests__/AdminAccounts.jsx @@ -4,6 +4,7 @@ import * as rtl from '@testing-library/react' import '@testing-library/jest-dom/extend-expect' import * as testUtils from 'utils/test-utils' +import * as AccountsTestUtils from 'utils/AccountsTestUtils' import AdminAccounts from 'pages/Admin/Accounts/AdminAccounts' import * as AccountService from 'services/accounts' @@ -17,22 +18,6 @@ afterEach(rtl.cleanup) const renderPage = () => testUtils.renderWithRouter() -const waitForAccountsToLoad = page => - rtl.waitForElementToBeRemoved(() => - page.queryAllByTestId('accountRowPlaceholder'), - ) - -const queryAllByColumnText = (container, columnId, text) => - rtl - .queryAllByText(container, text) - .filter(el => el.getAttribute('data-account-column-id') === columnId) - -const queryByColumnText = (container, columnId, text) => - _.get(queryAllByColumnText(container, columnId, text), [0], null) - -const expectEmailIn = (email, container) => - expect(queryByColumnText(container, 'emailAddress', email)).not.toBeNull() - describe('AdminAccounts page', () => { it('renders', async () => { AccountService.fetchAdminAccounts = jest.fn().mockResolvedValue([]) @@ -54,10 +39,10 @@ describe('AdminAccounts page', () => { .fn() .mockResolvedValueOnce(MOCK_ADMINS) const page = renderPage() - await waitForAccountsToLoad(page) + await AccountsTestUtils.waitForAccountsToLoad(page) _.take(_.range(0, NUM_MOCK_ACCOUNTS, 10), 10).forEach(index => - expectEmailIn(`${index}@example.com`, page.baseElement), + AccountsTestUtils.expectEmailIn(`${index}@example.com`, page.baseElement), ) }) @@ -67,7 +52,7 @@ describe('AdminAccounts page', () => { .mockResolvedValueOnce(MOCK_ADMINS) const page = renderPage() - await waitForAccountsToLoad(page) + await AccountsTestUtils.waitForAccountsToLoad(page) const pagination = page.getByRole('navigation') const page1Button = rtl.queryByText(pagination, '1') @@ -76,7 +61,7 @@ describe('AdminAccounts page', () => { const page2Button = rtl.queryByText(pagination, '2') expect(page2Button).not.toBeNull() rtl.fireEvent.click(page2Button) - expectEmailIn('150@example.com', page.baseElement) + AccountsTestUtils.expectEmailIn('150@example.com', page.baseElement) }) it('orders accounts by email address', async () => { @@ -85,7 +70,7 @@ describe('AdminAccounts page', () => { .mockResolvedValueOnce(MOCK_ADMINS) const page = renderPage() - await waitForAccountsToLoad(page) + await AccountsTestUtils.waitForAccountsToLoad(page) // Order ascending const table = page.getByTestId('accountsTable') @@ -95,7 +80,9 @@ describe('AdminAccounts page', () => { // Check that first page is correct ;[0, 100, 10, 110, 120, 130, 140, 150, 20, 30] .map(index => `${index}@example.com`) - .forEach(emailAddress => expectEmailIn(emailAddress, table)) + .forEach(emailAddress => + AccountsTestUtils.expectEmailIn(emailAddress, table), + ) // Check that last page is correct const pagination = page.getByRole('navigation') @@ -103,7 +90,9 @@ describe('AdminAccounts page', () => { rtl.fireEvent.click(lastPageButton) ;[40, 50, 60, 70, 80, 90] .map(index => `${index}@example.com`) - .forEach(emailAddress => expectEmailIn(emailAddress, table)) + .forEach(emailAddress => + AccountsTestUtils.expectEmailIn(emailAddress, table), + ) // Order descending, go back to first page rtl.fireEvent.click(emailAddressHeader) @@ -113,7 +102,9 @@ describe('AdminAccounts page', () => { // Check that first page is correct ;[90, 80, 70, 60, 50, 40, 30, 20, 150, 140] .map(index => `${index}@example.com`) - .forEach(emailAddress => expectEmailIn(emailAddress, table)) + .forEach(emailAddress => + AccountsTestUtils.expectEmailIn(emailAddress, table), + ) }) it('orders accounts by date promoted', async () => { @@ -122,7 +113,7 @@ describe('AdminAccounts page', () => { .mockResolvedValueOnce(MOCK_ADMINS) const page = renderPage() - await waitForAccountsToLoad(page) + await AccountsTestUtils.waitForAccountsToLoad(page) // Order ascending const table = page.getByTestId('accountsTable') @@ -132,7 +123,9 @@ describe('AdminAccounts page', () => { // Check that first page is correct ;[110, 60, 120, 70, 20, 130, 80, 30, 140, 90] .map(index => `${index}@example.com`) - .forEach(emailAddress => expectEmailIn(emailAddress, table)) + .forEach(emailAddress => + AccountsTestUtils.expectEmailIn(emailAddress, table), + ) }) it('filters accounts by email address', async () => { @@ -141,19 +134,25 @@ describe('AdminAccounts page', () => { .mockResolvedValueOnce(MOCK_ADMINS) const page = renderPage() - await waitForAccountsToLoad(page) + await AccountsTestUtils.waitForAccountsToLoad(page) const filterInput = page.getByPlaceholderText('Search by...') const table = page.getByTestId('accountsTable') rtl.fireEvent.change(filterInput, { target: { value: '1' } }) ;[110, 120, 130, 140, 150, 100, 10] .map(index => `${index}@example.com`) - .forEach(emailAddress => expectEmailIn(emailAddress, table)) + .forEach(emailAddress => + AccountsTestUtils.expectEmailIn(emailAddress, table), + ) rtl.fireEvent.change(filterInput, { target: { value: '9' } }) rtl.getByText(table, '90@example.com') expect( - queryAllByColumnText(table, 'emailAddress', /@example\.com/), + AccountsTestUtils.queryAllByColumnText( + table, + 'emailAddress', + /@example\.com/, + ), ).toHaveLength(1) }) @@ -163,7 +162,7 @@ describe('AdminAccounts page', () => { .mockResolvedValueOnce(MOCK_ADMINS) const page = renderPage() - await waitForAccountsToLoad(page) + await AccountsTestUtils.waitForAccountsToLoad(page) const filterInput = page.getByPlaceholderText('Search by...') const filterDropdown = page.getByTestId('filterDropdown') const table = page.getByTestId('accountsTable') @@ -175,14 +174,24 @@ describe('AdminAccounts page', () => { rtl.fireEvent.change(filterInput, { target: { value: '20@example.com' } }) ;[90, 100, 110] .map(index => `${index}@example.com`) - .forEach(emailAddress => expectEmailIn(emailAddress, table)) + .forEach(emailAddress => + AccountsTestUtils.expectEmailIn(emailAddress, table), + ) expect( - queryAllByColumnText(table, 'emailAddress', /@example\.com/), + AccountsTestUtils.queryAllByColumnText( + table, + 'emailAddress', + /@example\.com/, + ), ).toHaveLength(3) rtl.fireEvent.change(filterInput, { target: { value: '30@example.com' } }) expect( - queryAllByColumnText(table, 'emailAddress', /@example\.com/), + AccountsTestUtils.queryAllByColumnText( + table, + 'emailAddress', + /@example\.com/, + ), ).toHaveLength(0) }) @@ -192,7 +201,7 @@ describe('AdminAccounts page', () => { .mockResolvedValueOnce(MOCK_ADMINS) const page = renderPage() - await waitForAccountsToLoad(page) + await AccountsTestUtils.waitForAccountsToLoad(page) const filterInput = page.getByPlaceholderText('Search by...') const filterDropdown = page.getByTestId('filterDropdown') const table = page.getByTestId('accountsTable') @@ -204,14 +213,24 @@ describe('AdminAccounts page', () => { rtl.fireEvent.change(filterInput, { target: { value: 'identityPoolId20' } }) ;[90, 100, 110] .map(index => `${index}@example.com`) - .forEach(emailAddress => expectEmailIn(emailAddress, table)) + .forEach(emailAddress => + AccountsTestUtils.expectEmailIn(emailAddress, table), + ) expect( - queryAllByColumnText(table, 'emailAddress', /@example\.com/), + AccountsTestUtils.queryAllByColumnText( + table, + 'emailAddress', + /@example\.com/, + ), ).toHaveLength(3) rtl.fireEvent.change(filterInput, { target: { value: 'identityPoolId30' } }) expect( - queryAllByColumnText(table, 'emailAddress', /@example\.com/), + AccountsTestUtils.queryAllByColumnText( + table, + 'emailAddress', + /@example\.com/, + ), ).toHaveLength(0) }) }) diff --git a/dev-portal/src/utils/AccountsTestUtils.jsx b/dev-portal/src/utils/AccountsTestUtils.jsx new file mode 100644 index 000000000..a143a8af7 --- /dev/null +++ b/dev-portal/src/utils/AccountsTestUtils.jsx @@ -0,0 +1,26 @@ +import _ from 'lodash' +import * as rtl from '@testing-library/react' + +import * as AccountsTable from 'components/Admin/Accounts/AccountsTable' +import * as AccountsTableColumns from 'components/Admin/Accounts/AccountsTableColumns' + +export const waitForAccountsToLoad = page => + rtl.waitForElementToBeRemoved(() => + page.queryAllByTestId(AccountsTable.ACCOUNT_ROW_PLACEHOLDER_TESTID), + ) + +export const queryAllByColumnText = (container, columnId, text) => + rtl + .queryAllByText(container, text) + .filter( + el => + el.getAttribute(AccountsTable.ACCOUNT_COLUMN_ID_DATA_ATTR) === columnId, + ) + +export const queryByColumnText = (container, columnId, text) => + _.get(queryAllByColumnText(container, columnId, text), [0], null) + +export const expectEmailIn = (email, container) => + expect( + queryByColumnText(container, AccountsTableColumns.EmailAddress.id, email), + ).toBeInTheDocument() From 0d2c1b5575e3392efaccd5fcfcdc6a57fa251c62 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Mon, 5 Aug 2019 14:46:25 -0700 Subject: [PATCH 17/45] AdminAccounts: clean up tests --- .../Admin/Accounts/AccountsTable.jsx | 12 +- .../Accounts/__tests__/AdminAccounts.jsx | 140 +++++++++++------- 2 files changed, 93 insertions(+), 59 deletions(-) diff --git a/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx b/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx index 5c0f2c3da..8ca0db189 100644 --- a/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx +++ b/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx @@ -38,6 +38,8 @@ const ORDER_DIRECTIONS = [ const nextDirectionIndex = index => (index + 1) % ORDER_DIRECTIONS.length export const ACCOUNT_ROW_PLACEHOLDER_TESTID = 'accountRowPlaceholder' +export const FILTER_DROPDOWN_TESTID = 'filterDropdown' +export const ACCOUNTS_TABLE_TESTID = 'accountsTable' export const ACCOUNT_COLUMN_ID_DATA_ATTR = 'data-account-column-id' /** @@ -230,7 +232,7 @@ export const AccountsTable = ({ options={filterColumnDropdownOptions} selection value={filter.column.id} - data-testid='filterDropdown' + data-testid={FILTER_DROPDOWN_TESTID} />
@@ -240,8 +242,12 @@ export const AccountsTable = ({ ) const table = ( -
- +
+ {tableRows} diff --git a/dev-portal/src/pages/Admin/Accounts/__tests__/AdminAccounts.jsx b/dev-portal/src/pages/Admin/Accounts/__tests__/AdminAccounts.jsx index 62b6d811c..08af8c383 100644 --- a/dev-portal/src/pages/Admin/Accounts/__tests__/AdminAccounts.jsx +++ b/dev-portal/src/pages/Admin/Accounts/__tests__/AdminAccounts.jsx @@ -7,6 +7,7 @@ import * as testUtils from 'utils/test-utils' import * as AccountsTestUtils from 'utils/AccountsTestUtils' import AdminAccounts from 'pages/Admin/Accounts/AdminAccounts' +import * as AccountsTable from 'components/Admin/Accounts/AccountsTable' import * as AccountService from 'services/accounts' jest.mock('services/accounts') @@ -31,7 +32,9 @@ describe('AdminAccounts page', () => { .mockReturnValue(new Promise(() => {})) const page = renderPage() - expect(page.queryAllByTestId('accountRowPlaceholder')).not.toHaveLength(0) + expect( + page.queryAllByTestId(AccountsTable.ACCOUNT_ROW_PLACEHOLDER_TESTID), + ).not.toHaveLength(0) }) it('shows the accounts after loading', async () => { @@ -41,8 +44,9 @@ describe('AdminAccounts page', () => { const page = renderPage() await AccountsTestUtils.waitForAccountsToLoad(page) - _.take(_.range(0, NUM_MOCK_ACCOUNTS, 10), 10).forEach(index => - AccountsTestUtils.expectEmailIn(`${index}@example.com`, page.baseElement), + _.take(MOCK_ADMINS, AccountsTable.DEFAULT_PAGE_SIZE).forEach( + ({ emailAddress }) => + AccountsTestUtils.expectEmailIn(emailAddress, page.baseElement), ) }) @@ -73,14 +77,15 @@ describe('AdminAccounts page', () => { await AccountsTestUtils.waitForAccountsToLoad(page) // Order ascending - const table = page.getByTestId('accountsTable') + const table = page.getByTestId(AccountsTable.ACCOUNTS_TABLE_TESTID) const emailAddressHeader = rtl.getByText(table, 'Email address') rtl.fireEvent.click(emailAddressHeader) // Check that first page is correct - ;[0, 100, 10, 110, 120, 130, 140, 150, 20, 30] - .map(index => `${index}@example.com`) - .forEach(emailAddress => + _(MOCK_ADMINS) + .orderBy(['emailAddress']) + .take(AccountsTable.DEFAULT_PAGE_SIZE) + .forEach(({ emailAddress }) => AccountsTestUtils.expectEmailIn(emailAddress, table), ) @@ -88,9 +93,13 @@ describe('AdminAccounts page', () => { const pagination = page.getByRole('navigation') const lastPageButton = rtl.getByLabelText(pagination, 'Last item') rtl.fireEvent.click(lastPageButton) - ;[40, 50, 60, 70, 80, 90] - .map(index => `${index}@example.com`) - .forEach(emailAddress => + _(MOCK_ADMINS) + .orderBy(['emailAddress']) + .drop( + Math.floor(MOCK_ADMINS.length / AccountsTable.DEFAULT_PAGE_SIZE) * + AccountsTable.DEFAULT_PAGE_SIZE, + ) + .forEach(({ emailAddress }) => AccountsTestUtils.expectEmailIn(emailAddress, table), ) @@ -100,9 +109,10 @@ describe('AdminAccounts page', () => { rtl.fireEvent.click(firstPageButton) // Check that first page is correct - ;[90, 80, 70, 60, 50, 40, 30, 20, 150, 140] - .map(index => `${index}@example.com`) - .forEach(emailAddress => + _(MOCK_ADMINS) + .orderBy(['emailAddress'], ['desc']) + .take(AccountsTable.DEFAULT_PAGE_SIZE) + .forEach(({ emailAddress }) => AccountsTestUtils.expectEmailIn(emailAddress, table), ) }) @@ -116,14 +126,15 @@ describe('AdminAccounts page', () => { await AccountsTestUtils.waitForAccountsToLoad(page) // Order ascending - const table = page.getByTestId('accountsTable') + const table = page.getByTestId(AccountsTable.ACCOUNTS_TABLE_TESTID) const dateRegisteredHeader = rtl.getByText(table, 'Date promoted') rtl.fireEvent.click(dateRegisteredHeader) // Check that first page is correct - ;[110, 60, 120, 70, 20, 130, 80, 30, 140, 90] - .map(index => `${index}@example.com`) - .forEach(emailAddress => + _(MOCK_ADMINS) + .orderBy(['datePromoted'], ['asc']) + .take(AccountsTable.DEFAULT_PAGE_SIZE) + .forEach(({ emailAddress }) => AccountsTestUtils.expectEmailIn(emailAddress, table), ) }) @@ -136,24 +147,23 @@ describe('AdminAccounts page', () => { const page = renderPage() await AccountsTestUtils.waitForAccountsToLoad(page) const filterInput = page.getByPlaceholderText('Search by...') - const table = page.getByTestId('accountsTable') + const table = page.getByTestId(AccountsTable.ACCOUNTS_TABLE_TESTID) rtl.fireEvent.change(filterInput, { target: { value: '1' } }) - ;[110, 120, 130, 140, 150, 100, 10] - .map(index => `${index}@example.com`) - .forEach(emailAddress => + _(MOCK_ADMINS) + .filter(({ emailAddress }) => emailAddress.includes('1')) + .forEach(({ emailAddress }) => AccountsTestUtils.expectEmailIn(emailAddress, table), ) rtl.fireEvent.change(filterInput, { target: { value: '9' } }) - rtl.getByText(table, '90@example.com') expect( AccountsTestUtils.queryAllByColumnText( table, 'emailAddress', /@example\.com/, - ), - ).toHaveLength(1) + ).map(el => el.textContent), + ).toEqual(['90@example.com']) }) it('filters accounts by promoter email address', async () => { @@ -164,17 +174,19 @@ describe('AdminAccounts page', () => { const page = renderPage() await AccountsTestUtils.waitForAccountsToLoad(page) const filterInput = page.getByPlaceholderText('Search by...') - const filterDropdown = page.getByTestId('filterDropdown') - const table = page.getByTestId('accountsTable') + const filterDropdown = page.getByTestId( + AccountsTable.FILTER_DROPDOWN_TESTID, + ) + const table = page.getByTestId(AccountsTable.ACCOUNTS_TABLE_TESTID) rtl.fireEvent.click(filterDropdown) const filterByApiKeyIdOption = rtl.getByText(filterDropdown, 'Promoter') rtl.fireEvent.click(filterByApiKeyIdOption) rtl.fireEvent.change(filterInput, { target: { value: '20@example.com' } }) - ;[90, 100, 110] - .map(index => `${index}@example.com`) - .forEach(emailAddress => + _(MOCK_ADMINS) + .filter({ promoterEmailAddress: '20@example.com' }) + .forEach(({ emailAddress }) => AccountsTestUtils.expectEmailIn(emailAddress, table), ) expect( @@ -203,26 +215,32 @@ describe('AdminAccounts page', () => { const page = renderPage() await AccountsTestUtils.waitForAccountsToLoad(page) const filterInput = page.getByPlaceholderText('Search by...') - const filterDropdown = page.getByTestId('filterDropdown') - const table = page.getByTestId('accountsTable') + const filterDropdown = page.getByTestId( + AccountsTable.FILTER_DROPDOWN_TESTID, + ) + const table = page.getByTestId(AccountsTable.ACCOUNTS_TABLE_TESTID) rtl.fireEvent.click(filterDropdown) const filterByApiKeyIdOption = rtl.getByText(filterDropdown, 'Promoter') rtl.fireEvent.click(filterByApiKeyIdOption) rtl.fireEvent.change(filterInput, { target: { value: 'identityPoolId20' } }) - ;[90, 100, 110] - .map(index => `${index}@example.com`) - .forEach(emailAddress => - AccountsTestUtils.expectEmailIn(emailAddress, table), + const expectedEmails = _(MOCK_ADMINS) + .filter(({ promoterIdentityPoolId }) => + (promoterIdentityPoolId || '').includes('identityPoolId20'), ) + .map(({ emailAddress }) => emailAddress) + .sortBy() + .value() expect( - AccountsTestUtils.queryAllByColumnText( - table, - 'emailAddress', - /@example\.com/, + _.sortBy( + AccountsTestUtils.queryAllByColumnText( + table, + 'emailAddress', + /@example\.com/, + ).map(el => el.textContent), ), - ).toHaveLength(3) + ).toEqual(expectedEmails) rtl.fireEvent.change(filterInput, { target: { value: 'identityPoolId30' } }) expect( @@ -237,31 +255,41 @@ describe('AdminAccounts page', () => { const NUM_MOCK_ACCOUNTS = 157 // should be prime -const MOCK_ACCOUNTS = (() => { +const MOCK_PROMOTERS = _.range(NUM_MOCK_ACCOUNTS).map(index => { + if (_.inRange(index, 20, 90)) { + return 10 + } else if (_.inRange(index, 90, 120)) { + return 20 + } else if (_.inRange(index, 120, NUM_MOCK_ACCOUNTS)) { + return 100 + } + return null +}) + +const MOCK_DATES_PROMOTED = (() => { const now = Date.now() - const adminStep = 10 - return Array.from({ length: NUM_MOCK_ACCOUNTS }).map((_value, index) => { - let promoter = null - if (_.inRange(index, 20, 90)) { - promoter = 10 - } else if (_.inRange(index, 90, 120)) { - promoter = 20 - } else if (_.inRange(index, 120, NUM_MOCK_ACCOUNTS)) { - promoter = 100 - } + return _.range(NUM_MOCK_ACCOUNTS).map( + index => + MOCK_PROMOTERS[index] && + new Date(now + ((index * 3) % NUM_MOCK_ACCOUNTS) * 1000), + ) +})() + +const MOCK_ADMIN_STEP = 10 +const MOCK_ACCOUNTS = (() => { + return Array.from({ length: NUM_MOCK_ACCOUNTS }).map((_value, index) => { + const promoter = MOCK_PROMOTERS[index] return { identityPoolId: `identityPoolId${index}`, userPoolId: `userPoolId${index}`, emailAddress: `${index}@example.com`, - datePromoted: - promoter && - new Date(now + ((index * 3) % NUM_MOCK_ACCOUNTS) * 1000).toJSON(), + datePromoted: MOCK_DATES_PROMOTED[index], promoterEmailAddress: promoter && `${promoter}@example.com`, promoterIdentityPoolId: promoter && `identityPoolId${promoter}`, - isAdmin: index % adminStep === 0, + isAdmin: index % MOCK_ADMIN_STEP === 0, } }) })() -const MOCK_ADMINS = () => MOCK_ACCOUNTS.filter(account => account.isAdmin) +const MOCK_ADMINS = MOCK_ACCOUNTS.filter(account => account.isAdmin) From cb02023c9c2134d025caca1668edb7eb0cd10653 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Mon, 5 Aug 2019 15:19:24 -0700 Subject: [PATCH 18/45] AccountsTestUtils: add docs --- dev-portal/src/utils/AccountsTestUtils.jsx | 37 ++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/dev-portal/src/utils/AccountsTestUtils.jsx b/dev-portal/src/utils/AccountsTestUtils.jsx index a143a8af7..a880fb709 100644 --- a/dev-portal/src/utils/AccountsTestUtils.jsx +++ b/dev-portal/src/utils/AccountsTestUtils.jsx @@ -4,11 +4,31 @@ import * as rtl from '@testing-library/react' import * as AccountsTable from 'components/Admin/Accounts/AccountsTable' import * as AccountsTableColumns from 'components/Admin/Accounts/AccountsTableColumns' -export const waitForAccountsToLoad = page => +/** + * A promise that waits for `AccountsTable.LoadingAccountRow` elements to be + * removed. Has the same operating restriction as `waitForElementToBeRemoved`; + * namely, that such an element must be present when this function is first + * called. + * + * @param container + * the element in which to search for `LoadingAccountRow` elements + */ +export const waitForAccountsToLoad = container => rtl.waitForElementToBeRemoved(() => - page.queryAllByTestId(AccountsTable.ACCOUNT_ROW_PLACEHOLDER_TESTID), + container.queryAllByTestId(AccountsTable.ACCOUNT_ROW_PLACEHOLDER_TESTID), ) +/** + * Like a `queryAll` from RTL, but searches within the `container` element for + * `text` within the column identified by `columnId`. + * + * @param container + * the element in which to search for `text` + * @param columnId + * the `id` of a column descriptor (see `AccountsTableColumns`) + * @param text + * the `TextMatch` for which to search + */ export const queryAllByColumnText = (container, columnId, text) => rtl .queryAllByText(container, text) @@ -17,9 +37,22 @@ export const queryAllByColumnText = (container, columnId, text) => el.getAttribute(AccountsTable.ACCOUNT_COLUMN_ID_DATA_ATTR) === columnId, ) +/** + * Like `queryAllByColumnText`, but returns only one element if found, or + * `null` if no elements are found. + */ export const queryByColumnText = (container, columnId, text) => _.get(queryAllByColumnText(container, columnId, text), [0], null) +/** + * Expects that `email` appears in the `EmailAddress` column of the + * `container`. + * + * @param email + * the `TextMatch` representing an email address, for which to search + * @param container + * the element in which to search + */ export const expectEmailIn = (email, container) => expect( queryByColumnText(container, AccountsTableColumns.EmailAddress.id, email), From 16ec0125ed750ae26a85a2a039d6bf7446b07b20 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Fri, 9 Aug 2019 14:50:30 -0700 Subject: [PATCH 19/45] Rename AccountsTestUtils -> accounts-test-utils --- .../Accounts/__tests__/AdminAccounts.jsx | 42 +++++++++---------- ...sTestUtils.jsx => accounts-test-utils.jsx} | 0 2 files changed, 21 insertions(+), 21 deletions(-) rename dev-portal/src/utils/{AccountsTestUtils.jsx => accounts-test-utils.jsx} (100%) diff --git a/dev-portal/src/pages/Admin/Accounts/__tests__/AdminAccounts.jsx b/dev-portal/src/pages/Admin/Accounts/__tests__/AdminAccounts.jsx index 08af8c383..adb59b8a0 100644 --- a/dev-portal/src/pages/Admin/Accounts/__tests__/AdminAccounts.jsx +++ b/dev-portal/src/pages/Admin/Accounts/__tests__/AdminAccounts.jsx @@ -4,7 +4,7 @@ import * as rtl from '@testing-library/react' import '@testing-library/jest-dom/extend-expect' import * as testUtils from 'utils/test-utils' -import * as AccountsTestUtils from 'utils/AccountsTestUtils' +import * as accountsTestUtils from 'utils/accounts-test-utils' import AdminAccounts from 'pages/Admin/Accounts/AdminAccounts' import * as AccountsTable from 'components/Admin/Accounts/AccountsTable' @@ -42,11 +42,11 @@ describe('AdminAccounts page', () => { .fn() .mockResolvedValueOnce(MOCK_ADMINS) const page = renderPage() - await AccountsTestUtils.waitForAccountsToLoad(page) + await accountsTestUtils.waitForAccountsToLoad(page) _.take(MOCK_ADMINS, AccountsTable.DEFAULT_PAGE_SIZE).forEach( ({ emailAddress }) => - AccountsTestUtils.expectEmailIn(emailAddress, page.baseElement), + accountsTestUtils.expectEmailIn(emailAddress, page.baseElement), ) }) @@ -56,7 +56,7 @@ describe('AdminAccounts page', () => { .mockResolvedValueOnce(MOCK_ADMINS) const page = renderPage() - await AccountsTestUtils.waitForAccountsToLoad(page) + await accountsTestUtils.waitForAccountsToLoad(page) const pagination = page.getByRole('navigation') const page1Button = rtl.queryByText(pagination, '1') @@ -65,7 +65,7 @@ describe('AdminAccounts page', () => { const page2Button = rtl.queryByText(pagination, '2') expect(page2Button).not.toBeNull() rtl.fireEvent.click(page2Button) - AccountsTestUtils.expectEmailIn('150@example.com', page.baseElement) + accountsTestUtils.expectEmailIn('150@example.com', page.baseElement) }) it('orders accounts by email address', async () => { @@ -74,7 +74,7 @@ describe('AdminAccounts page', () => { .mockResolvedValueOnce(MOCK_ADMINS) const page = renderPage() - await AccountsTestUtils.waitForAccountsToLoad(page) + await accountsTestUtils.waitForAccountsToLoad(page) // Order ascending const table = page.getByTestId(AccountsTable.ACCOUNTS_TABLE_TESTID) @@ -86,7 +86,7 @@ describe('AdminAccounts page', () => { .orderBy(['emailAddress']) .take(AccountsTable.DEFAULT_PAGE_SIZE) .forEach(({ emailAddress }) => - AccountsTestUtils.expectEmailIn(emailAddress, table), + accountsTestUtils.expectEmailIn(emailAddress, table), ) // Check that last page is correct @@ -100,7 +100,7 @@ describe('AdminAccounts page', () => { AccountsTable.DEFAULT_PAGE_SIZE, ) .forEach(({ emailAddress }) => - AccountsTestUtils.expectEmailIn(emailAddress, table), + accountsTestUtils.expectEmailIn(emailAddress, table), ) // Order descending, go back to first page @@ -113,7 +113,7 @@ describe('AdminAccounts page', () => { .orderBy(['emailAddress'], ['desc']) .take(AccountsTable.DEFAULT_PAGE_SIZE) .forEach(({ emailAddress }) => - AccountsTestUtils.expectEmailIn(emailAddress, table), + accountsTestUtils.expectEmailIn(emailAddress, table), ) }) @@ -123,7 +123,7 @@ describe('AdminAccounts page', () => { .mockResolvedValueOnce(MOCK_ADMINS) const page = renderPage() - await AccountsTestUtils.waitForAccountsToLoad(page) + await accountsTestUtils.waitForAccountsToLoad(page) // Order ascending const table = page.getByTestId(AccountsTable.ACCOUNTS_TABLE_TESTID) @@ -135,7 +135,7 @@ describe('AdminAccounts page', () => { .orderBy(['datePromoted'], ['asc']) .take(AccountsTable.DEFAULT_PAGE_SIZE) .forEach(({ emailAddress }) => - AccountsTestUtils.expectEmailIn(emailAddress, table), + accountsTestUtils.expectEmailIn(emailAddress, table), ) }) @@ -145,7 +145,7 @@ describe('AdminAccounts page', () => { .mockResolvedValueOnce(MOCK_ADMINS) const page = renderPage() - await AccountsTestUtils.waitForAccountsToLoad(page) + await accountsTestUtils.waitForAccountsToLoad(page) const filterInput = page.getByPlaceholderText('Search by...') const table = page.getByTestId(AccountsTable.ACCOUNTS_TABLE_TESTID) @@ -153,12 +153,12 @@ describe('AdminAccounts page', () => { _(MOCK_ADMINS) .filter(({ emailAddress }) => emailAddress.includes('1')) .forEach(({ emailAddress }) => - AccountsTestUtils.expectEmailIn(emailAddress, table), + accountsTestUtils.expectEmailIn(emailAddress, table), ) rtl.fireEvent.change(filterInput, { target: { value: '9' } }) expect( - AccountsTestUtils.queryAllByColumnText( + accountsTestUtils.queryAllByColumnText( table, 'emailAddress', /@example\.com/, @@ -172,7 +172,7 @@ describe('AdminAccounts page', () => { .mockResolvedValueOnce(MOCK_ADMINS) const page = renderPage() - await AccountsTestUtils.waitForAccountsToLoad(page) + await accountsTestUtils.waitForAccountsToLoad(page) const filterInput = page.getByPlaceholderText('Search by...') const filterDropdown = page.getByTestId( AccountsTable.FILTER_DROPDOWN_TESTID, @@ -187,10 +187,10 @@ describe('AdminAccounts page', () => { _(MOCK_ADMINS) .filter({ promoterEmailAddress: '20@example.com' }) .forEach(({ emailAddress }) => - AccountsTestUtils.expectEmailIn(emailAddress, table), + accountsTestUtils.expectEmailIn(emailAddress, table), ) expect( - AccountsTestUtils.queryAllByColumnText( + accountsTestUtils.queryAllByColumnText( table, 'emailAddress', /@example\.com/, @@ -199,7 +199,7 @@ describe('AdminAccounts page', () => { rtl.fireEvent.change(filterInput, { target: { value: '30@example.com' } }) expect( - AccountsTestUtils.queryAllByColumnText( + accountsTestUtils.queryAllByColumnText( table, 'emailAddress', /@example\.com/, @@ -213,7 +213,7 @@ describe('AdminAccounts page', () => { .mockResolvedValueOnce(MOCK_ADMINS) const page = renderPage() - await AccountsTestUtils.waitForAccountsToLoad(page) + await accountsTestUtils.waitForAccountsToLoad(page) const filterInput = page.getByPlaceholderText('Search by...') const filterDropdown = page.getByTestId( AccountsTable.FILTER_DROPDOWN_TESTID, @@ -234,7 +234,7 @@ describe('AdminAccounts page', () => { .value() expect( _.sortBy( - AccountsTestUtils.queryAllByColumnText( + accountsTestUtils.queryAllByColumnText( table, 'emailAddress', /@example\.com/, @@ -244,7 +244,7 @@ describe('AdminAccounts page', () => { rtl.fireEvent.change(filterInput, { target: { value: 'identityPoolId30' } }) expect( - AccountsTestUtils.queryAllByColumnText( + accountsTestUtils.queryAllByColumnText( table, 'emailAddress', /@example\.com/, diff --git a/dev-portal/src/utils/AccountsTestUtils.jsx b/dev-portal/src/utils/accounts-test-utils.jsx similarity index 100% rename from dev-portal/src/utils/AccountsTestUtils.jsx rename to dev-portal/src/utils/accounts-test-utils.jsx From 1d9e21b9293ef2e64e5680b12db657267ffa8717 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Fri, 9 Aug 2019 15:10:49 -0700 Subject: [PATCH 20/45] RegisteredAccounts: use waitForAccountsToLoad from accounts-test-utils --- .../Accounts/__tests__/RegisteredAccounts.jsx | 36 +++++++++---------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx b/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx index e1644def9..5f8d1d7fb 100644 --- a/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx +++ b/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx @@ -4,6 +4,7 @@ import * as rtl from '@testing-library/react' import '@testing-library/jest-dom/extend-expect' import * as testUtils from 'utils/test-utils' +import * as accountsTestUtils from 'utils/accounts-test-utils' import RegisteredAccounts from 'pages/Admin/Accounts/RegisteredAccounts' import * as AccountsTable from 'components/Admin/Accounts/AccountsTable' @@ -18,11 +19,6 @@ afterEach(rtl.cleanup) const renderPage = () => testUtils.renderWithRouter() -const waitForAccountsToLoad = page => - rtl.waitForElementToBeRemoved(() => - page.queryAllByTestId('accountRowPlaceholder'), - ) - describe('RegisteredAccounts page', () => { it('renders', async () => { AccountService.fetchRegisteredAccounts = jest.fn().mockResolvedValue([]) @@ -44,7 +40,7 @@ describe('RegisteredAccounts page', () => { .fn() .mockResolvedValueOnce(MOCK_ACCOUNTS) const page = renderPage() - await waitForAccountsToLoad(page) + await accountsTestUtils.waitForAccountsToLoad(page) _.range(AccountsTable.DEFAULT_PAGE_SIZE).forEach(index => expect(page.queryByText(`${index}@example.com`)).not.toBeNull(), @@ -57,7 +53,7 @@ describe('RegisteredAccounts page', () => { .mockResolvedValueOnce(MOCK_ACCOUNTS) const page = renderPage() - await waitForAccountsToLoad(page) + await accountsTestUtils.waitForAccountsToLoad(page) const pagination = page.getByRole('navigation') const page1Button = rtl.queryByText(pagination, '1') @@ -77,7 +73,7 @@ describe('RegisteredAccounts page', () => { .mockResolvedValueOnce(MOCK_ACCOUNTS) const page = renderPage() - await waitForAccountsToLoad(page) + await accountsTestUtils.waitForAccountsToLoad(page) // Order ascending const table = page.getByTestId('accountsTable') @@ -120,7 +116,7 @@ describe('RegisteredAccounts page', () => { .mockResolvedValueOnce(MOCK_ACCOUNTS) const page = renderPage() - await waitForAccountsToLoad(page) + await accountsTestUtils.waitForAccountsToLoad(page) // Order ascending const table = page.getByTestId('accountsTable') @@ -140,7 +136,7 @@ describe('RegisteredAccounts page', () => { .mockResolvedValueOnce(MOCK_ACCOUNTS) const page = renderPage() - await waitForAccountsToLoad(page) + await accountsTestUtils.waitForAccountsToLoad(page) const filterInput = page.getByPlaceholderText('Search by...') const table = page.getByTestId('accountsTable') @@ -164,7 +160,7 @@ describe('RegisteredAccounts page', () => { .mockResolvedValueOnce(MOCK_ACCOUNTS) const page = renderPage() - await waitForAccountsToLoad(page) + await accountsTestUtils.waitForAccountsToLoad(page) const filterInput = page.getByPlaceholderText('Search by...') const filterDropdown = page.getByTestId('filterDropdown') const table = page.getByTestId('accountsTable') @@ -193,7 +189,7 @@ describe('RegisteredAccounts page', () => { .mockResolvedValueOnce(MOCK_ACCOUNTS) const page = renderPage() - await waitForAccountsToLoad(page) + await accountsTestUtils.waitForAccountsToLoad(page) const filterInput = page.getByPlaceholderText('Search by...') const table = page.getByTestId('accountsTable') const dateRegisteredHeader = rtl.getByText(table, 'Date registered') @@ -224,7 +220,7 @@ describe('RegisteredAccounts page', () => { .mockResolvedValueOnce(undefined) const page = renderPage() - await waitForAccountsToLoad(page) + await accountsTestUtils.waitForAccountsToLoad(page) const table = page.getByTestId('accountsTable') const targetAccountEmailCell = rtl.getByText(table, targetAccountEmail) const deleteButton = page.getByText('Delete') @@ -239,7 +235,7 @@ describe('RegisteredAccounts page', () => { rtl.getByText(modal, targetAccountEmail) rtl.fireEvent.click(confirmDeleteButton) - await waitForAccountsToLoad(page) + await accountsTestUtils.waitForAccountsToLoad(page) expect(rtl.queryByText(document, 'Confirm deletion')).toBeNull() expect( AccountService.deleteAccountByIdentityPoolId.mock.calls, @@ -266,7 +262,7 @@ describe('RegisteredAccounts page', () => { .mockImplementation(() => Promise.reject(new Error(errorMessage))) const page = renderPage() - await waitForAccountsToLoad(page) + await accountsTestUtils.waitForAccountsToLoad(page) const table = page.getByTestId('accountsTable') const targetAccountEmailCell = rtl.getByText(table, targetAccountEmail) const deleteButton = page.getByText('Delete') @@ -278,7 +274,7 @@ describe('RegisteredAccounts page', () => { rtl.getByText(modal, targetAccountEmail) rtl.fireEvent.click(confirmDeleteButton) - await waitForAccountsToLoad(page) + await accountsTestUtils.waitForAccountsToLoad(page) await rtl.wait(() => expect(page.getByText(/Failed to delete account/)).toBeInTheDocument(), ) @@ -297,7 +293,7 @@ describe('RegisteredAccounts page', () => { .mockResolvedValueOnce(undefined) const page = renderPage() - await waitForAccountsToLoad(page) + await accountsTestUtils.waitForAccountsToLoad(page) const table = page.getByTestId('accountsTable') const targetAccountEmailCell = rtl.getByText(table, targetAccountEmail) const promoteButton = page.getByText('Promote to Admin') @@ -312,7 +308,7 @@ describe('RegisteredAccounts page', () => { rtl.getByText(modal, targetAccountEmail) rtl.fireEvent.click(confirmPromoteButton) - await waitForAccountsToLoad(page) + await accountsTestUtils.waitForAccountsToLoad(page) expect(rtl.queryByText(document, 'Confirm promotion')).toBeNull() expect( AccountService.promoteAccountByIdentityPoolId.mock.calls, @@ -339,7 +335,7 @@ describe('RegisteredAccounts page', () => { .mockImplementation(() => Promise.reject(new Error(errorMessage))) const page = renderPage() - await waitForAccountsToLoad(page) + await accountsTestUtils.waitForAccountsToLoad(page) const table = page.getByTestId('accountsTable') const targetAccountEmailCell = rtl.getByText(table, targetAccountEmail) const deleteButton = page.getByText('Delete') @@ -351,7 +347,7 @@ describe('RegisteredAccounts page', () => { rtl.getByText(modal, targetAccountEmail) rtl.fireEvent.click(confirmDeleteButton) - await waitForAccountsToLoad(page) + await accountsTestUtils.waitForAccountsToLoad(page) await rtl.wait(() => expect(page.getByText(/Failed to delete account/)).toBeInTheDocument(), ) From 77a951de0971d9a40a91f7c80f2e76a599634d95 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Fri, 9 Aug 2019 15:22:55 -0700 Subject: [PATCH 21/45] AccountsTable: typo and formatting --- .../src/components/Admin/Accounts/AccountsTable.jsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx b/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx index 8ca0db189..663dd9dda 100644 --- a/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx +++ b/dev-portal/src/components/Admin/Accounts/AccountsTable.jsx @@ -111,10 +111,11 @@ export const AccountsTable = ({ }, [columns, filter]) /** - * Sets `accountsView` to the filtered subset of `props.accounts`. + * Sets `accountsView` to the filtered and sorted subset of `props.accounts`. */ useEffect(() => { let view = _(accounts) + if (filter.value !== '' && filter.column !== NO_FILTER_COLUMN) { const filterAccessor = filter.column.filtering.accessor if (typeof filterAccessor === 'string') { @@ -138,6 +139,7 @@ export const AccountsTable = ({ [ORDER_DIRECTIONS[order.directionIndex].lodashDirection], ) } + setAccountsView(view.value()) }, [accounts, filter, order]) @@ -243,11 +245,7 @@ export const AccountsTable = ({ const table = (
- + {tableRows} @@ -341,7 +339,7 @@ const AccountRow = React.memo(({ account, columns, isSelected, onSelect }) => { return ( onSelect(account)}> {columns.map(({ id, render }, index) => ( - + {render(account)} ))} From 3e0ef7b4ccc82bbfc69cbe8ab9016027b2eb6d93 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Fri, 9 Aug 2019 16:33:00 -0700 Subject: [PATCH 22/45] Add PendingRequests UI --- .../Admin/Accounts/AccountsTableColumns.jsx | 9 + .../pages/Admin/Accounts/AccountRequests.jsx | 7 - .../pages/Admin/Accounts/PendingRequests.jsx | 183 +++++++++++++++++ .../Accounts/__tests__/PendingRequests.jsx | 187 ++++++++++++++++++ dev-portal/src/pages/Admin/Admin.jsx | 4 +- dev-portal/src/services/accounts.js | 38 ++++ 6 files changed, 419 insertions(+), 9 deletions(-) delete mode 100644 dev-portal/src/pages/Admin/Accounts/AccountRequests.jsx create mode 100644 dev-portal/src/pages/Admin/Accounts/PendingRequests.jsx create mode 100644 dev-portal/src/pages/Admin/Accounts/__tests__/PendingRequests.jsx diff --git a/dev-portal/src/components/Admin/Accounts/AccountsTableColumns.jsx b/dev-portal/src/components/Admin/Accounts/AccountsTableColumns.jsx index 24fd610bb..4e3f05842 100644 --- a/dev-portal/src/components/Admin/Accounts/AccountsTableColumns.jsx +++ b/dev-portal/src/components/Admin/Accounts/AccountsTableColumns.jsx @@ -82,6 +82,15 @@ export const DatePromoted = { }, } +export const DateRequested = { + id: 'dateRequested', + title: 'Date requested', + render: account => formatDate(account.dateRequested), + ordering: { + iteratee: 'dateRequested', + }, +} + const DATE_TIME_FORMATTER = new Intl.DateTimeFormat('default', { year: 'numeric', month: 'numeric', diff --git a/dev-portal/src/pages/Admin/Accounts/AccountRequests.jsx b/dev-portal/src/pages/Admin/Accounts/AccountRequests.jsx deleted file mode 100644 index 85930af11..000000000 --- a/dev-portal/src/pages/Admin/Accounts/AccountRequests.jsx +++ /dev/null @@ -1,7 +0,0 @@ -import React, { Component } from 'react' - -export default class AccountRequests extends Component { - render = () => { - return

TODO: Account requests

- } -} diff --git a/dev-portal/src/pages/Admin/Accounts/PendingRequests.jsx b/dev-portal/src/pages/Admin/Accounts/PendingRequests.jsx new file mode 100644 index 000000000..c610d1fe4 --- /dev/null +++ b/dev-portal/src/pages/Admin/Accounts/PendingRequests.jsx @@ -0,0 +1,183 @@ +import React, { useCallback, useEffect, useState } from 'react' +import { Button, Container, Header, Message, Modal } from 'semantic-ui-react' + +import * as MessageList from 'components/MessageList' +import * as AccountService from 'services/accounts' +import * as AccountsTable from 'components/Admin/Accounts/AccountsTable' +import * as AccountsTableColumns from 'components/Admin/Accounts/AccountsTableColumns' + +const PendingRequests = () => { + const [accounts, setAccounts] = useState([]) + const [loading, setLoading] = useState(true) + const [selectedAccount, setSelectedAccount] = useState(undefined) + const [denyModalOpen, setDenyModalOpen] = useState(false) + const [messages, sendMessage] = MessageList.useMessages() + + const refreshAccounts = () => + AccountService.fetchPendingRequestAccounts().then(accounts => + setAccounts(accounts), + ) + + // Initial load + useEffect(() => { + refreshAccounts().finally(() => setLoading(false)) + }, []) + + const onSelectAccount = useCallback(account => setSelectedAccount(account), [ + setSelectedAccount, + ]) + + const onConfirmApprove = useCallback(async () => { + setLoading(true) + try { + await AccountService.approveAccountRequestByIdentityPoolId( + selectedAccount.identityPoolId, + ) + sendMessage(dismiss => ( + + )) + await refreshAccounts() + } catch (error) { + sendMessage(dismiss => ( + + )) + } finally { + setLoading(false) + } + }, [sendMessage, selectedAccount]) + + const onConfirmDeny = useCallback(async () => { + setLoading(true) + setDenyModalOpen(false) + try { + await AccountService.denyAccountRequestByIdentityPoolId( + selectedAccount.identityPoolId, + ) + sendMessage(dismiss => ( + + )) + } catch (error) { + sendMessage(dismiss => ( + + )) + } finally { + setLoading(false) + } + }, [sendMessage, selectedAccount]) + + return ( + +
Pending requests
+ + + setDenyModalOpen(true)} + /> + + setDenyModalOpen(false)} + /> +
+ ) +} +export default PendingRequests + +const TableActions = React.memo( + ({ canApprove, onClickApprove, canDeny, onClickDeny }) => ( + + + + + + ), +) + +const ApproveSuccessMessage = React.memo(({ account, dismiss }) => ( + + + Approved account request for {account.emailAddress}. + + +)) + +const ApproveFailureMessage = React.memo( + ({ account, errorMessage, dismiss }) => ( + + +

+ Failed to approve account request for{' '} + {account.emailAddress}. +

+ {errorMessage &&

Error message: {errorMessage}

} +
+
+ ), +) + +const DenySuccessMessage = React.memo(({ account, dismiss }) => ( + + + Denied account request for {account.emailAddress}. + + +)) + +const DenyFailureMessage = React.memo(({ account, errorMessage, dismiss }) => ( + + +

+ Failed to deny account request for{' '} + {account.emailAddress}. +

+ {errorMessage &&

Error message: {errorMessage}

} +
+
+)) diff --git a/dev-portal/src/pages/Admin/Accounts/__tests__/PendingRequests.jsx b/dev-portal/src/pages/Admin/Accounts/__tests__/PendingRequests.jsx new file mode 100644 index 000000000..9c7d3115d --- /dev/null +++ b/dev-portal/src/pages/Admin/Accounts/__tests__/PendingRequests.jsx @@ -0,0 +1,187 @@ +import _ from 'lodash' +import React from 'react' +import * as rtl from '@testing-library/react' +import '@testing-library/jest-dom/extend-expect' + +import * as testUtils from 'utils/test-utils' +import * as accountsTestUtils from 'utils/accounts-test-utils' + +import PendingRequests from 'pages/Admin/Accounts/PendingRequests' +import * as AccountsTable from 'components/Admin/Accounts/AccountsTable' +import * as AccountService from 'services/accounts' + +jest.mock('services/accounts') + +//: remove when React 16.9 is released +testUtils.suppressReact16Dot8ActWarningsGlobally() + +afterEach(rtl.cleanup) + +const renderPage = () => testUtils.renderWithRouter() + +describe('PendingRequests page', () => { + it('renders', async () => { + AccountService.fetchPendingRequestAccounts = jest.fn().mockResolvedValue([]) + const page = renderPage() + expect(page.baseElement).toBeTruthy() + }) + + it('initially shows the loading state', async () => { + AccountService.fetchPendingRequestAccounts = jest + .fn() + .mockReturnValue(new Promise(() => {})) + + const page = renderPage() + expect( + page.queryAllByTestId(AccountsTable.ACCOUNT_ROW_PLACEHOLDER_TESTID), + ).not.toHaveLength(0) + }) + + it('shows the accounts after loading', async () => { + AccountService.fetchPendingRequestAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ACCOUNTS) + const page = renderPage() + await accountsTestUtils.waitForAccountsToLoad(page) + + _.take(MOCK_ACCOUNTS, AccountsTable.DEFAULT_PAGE_SIZE).forEach( + ({ emailAddress }) => + accountsTestUtils.expectEmailIn(emailAddress, page.baseElement), + ) + }) + + it('orders pages for all accounts', async () => { + AccountService.fetchPendingRequestAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ACCOUNTS) + + const page = renderPage() + await accountsTestUtils.waitForAccountsToLoad(page) + const pagination = page.getByRole('navigation') + + const page1Button = rtl.queryByText(pagination, '1') + expect(page1Button).not.toBeNull() + + const page16Button = rtl.queryByText(pagination, '16') + expect(page16Button).not.toBeNull() + rtl.fireEvent.click(page16Button) + accountsTestUtils.expectEmailIn('150@example.com', page.baseElement) + }) + + it('orders accounts by email address', async () => { + AccountService.fetchPendingRequestAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ACCOUNTS) + + const page = renderPage() + await accountsTestUtils.waitForAccountsToLoad(page) + + // Order ascending + const table = page.getByTestId(AccountsTable.ACCOUNTS_TABLE_TESTID) + const emailAddressHeader = rtl.getByText(table, 'Email address') + rtl.fireEvent.click(emailAddressHeader) + + // Check that first page is correct + _(MOCK_ACCOUNTS) + .orderBy(['emailAddress']) + .take(AccountsTable.DEFAULT_PAGE_SIZE) + .forEach(({ emailAddress }) => + accountsTestUtils.expectEmailIn(emailAddress, table), + ) + + // Check that last page is correct + const pagination = page.getByRole('navigation') + const lastPageButton = rtl.getByLabelText(pagination, 'Last item') + rtl.fireEvent.click(lastPageButton) + _(MOCK_ACCOUNTS) + .orderBy(['emailAddress']) + .drop( + Math.floor(MOCK_ACCOUNTS.length / AccountsTable.DEFAULT_PAGE_SIZE) * + AccountsTable.DEFAULT_PAGE_SIZE, + ) + .forEach(({ emailAddress }) => + accountsTestUtils.expectEmailIn(emailAddress, table), + ) + + // Order descending, go back to first page + rtl.fireEvent.click(emailAddressHeader) + const firstPageButton = rtl.getByLabelText(pagination, 'First item') + rtl.fireEvent.click(firstPageButton) + + // Check that first page is correct + _(MOCK_ACCOUNTS) + .orderBy(['emailAddress'], ['desc']) + .take(AccountsTable.DEFAULT_PAGE_SIZE) + .forEach(({ emailAddress }) => + accountsTestUtils.expectEmailIn(emailAddress, table), + ) + }) + + it('orders accounts by date requested', async () => { + AccountService.fetchPendingRequestAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ACCOUNTS) + + const page = renderPage() + await accountsTestUtils.waitForAccountsToLoad(page) + + // Order ascending + const table = page.getByTestId(AccountsTable.ACCOUNTS_TABLE_TESTID) + const dateRegisteredHeader = rtl.getByText(table, 'Date requested') + rtl.fireEvent.click(dateRegisteredHeader) + + // Check that first page is correct + _(MOCK_ACCOUNTS) + .orderBy(['dateRequested'], ['asc']) + .take(AccountsTable.DEFAULT_PAGE_SIZE) + .forEach(({ emailAddress }) => + accountsTestUtils.expectEmailIn(emailAddress, table), + ) + }) + + it('filters accounts by email address', async () => { + AccountService.fetchPendingRequestAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ACCOUNTS) + + const page = renderPage() + await accountsTestUtils.waitForAccountsToLoad(page) + const filterInput = page.getByPlaceholderText('Search by...') + const table = page.getByTestId(AccountsTable.ACCOUNTS_TABLE_TESTID) + + rtl.fireEvent.change(filterInput, { target: { value: '1' } }) + _(MOCK_ACCOUNTS) + .filter(({ emailAddress }) => emailAddress.includes('1')) + .take(AccountsTable.DEFAULT_PAGE_SIZE) + .forEach(({ emailAddress }) => + accountsTestUtils.expectEmailIn(emailAddress, table), + ) + + rtl.fireEvent.change(filterInput, { target: { value: '90' } }) + expect( + accountsTestUtils + .queryAllByColumnText(table, 'emailAddress', /@example\.com/) + .map(el => el.textContent), + ).toEqual(['90@example.com']) + }) +}) + +const NUM_MOCK_ACCOUNTS = 157 // should be prime + +const MOCK_DATES_REQUESTED = (() => { + const now = Date.now() + return _.range(NUM_MOCK_ACCOUNTS).map( + index => new Date(now + ((index * 3) % NUM_MOCK_ACCOUNTS) * 1000), + ) +})() + +const MOCK_ACCOUNTS = (() => { + return Array.from({ length: NUM_MOCK_ACCOUNTS }).map((_value, index) => { + return { + identityPoolId: `identityPoolId${index}`, + userPoolId: `userPoolId${index}`, + emailAddress: `${index}@example.com`, + dateRequested: MOCK_DATES_REQUESTED[index], + } + }) +})() diff --git a/dev-portal/src/pages/Admin/Admin.jsx b/dev-portal/src/pages/Admin/Admin.jsx index 648fb81c3..47caa5183 100644 --- a/dev-portal/src/pages/Admin/Admin.jsx +++ b/dev-portal/src/pages/Admin/Admin.jsx @@ -7,7 +7,7 @@ import { AdminRoute } from 'index' import RegisteredAccounts from 'pages/Admin/Accounts/RegisteredAccounts' import AdminAccounts from 'pages/Admin/Accounts/AdminAccounts' import AccountInvites from 'pages/Admin/Accounts/AccountInvites' -import AccountRequests from 'pages/Admin/Accounts/AccountRequests' +import PendingRequests from 'pages/Admin/Accounts/PendingRequests' export class Admin extends Component { render() { @@ -21,7 +21,7 @@ export class Admin extends Component { - + diff --git a/dev-portal/src/services/accounts.js b/dev-portal/src/services/accounts.js index 20e913316..e2daeec0a 100644 --- a/dev-portal/src/services/accounts.js +++ b/dev-portal/src/services/accounts.js @@ -35,6 +35,10 @@ const mockData = (() => { }) })() +const mockPendingRequestAccounts = _.cloneDeep(mockData).map( + ({ dateRegistered, ...rest }) => ({ ...rest, dateRequested: dateRegistered }), +) + export const fetchRegisteredAccounts = () => { return resolveAfter(1500, mockData.slice()) } @@ -43,6 +47,10 @@ export const fetchAdminAccounts = () => { return resolveAfter(1500, mockData.filter(account => account.isAdmin)) } +export const fetchPendingRequestAccounts = () => { + return resolveAfter(1500, mockPendingRequestAccounts.slice()) +} + export const deleteAccountByIdentityPoolId = async identityPoolId => { await resolveAfter(1500) @@ -72,3 +80,33 @@ export const promoteAccountByIdentityPoolId = async identityPoolId => { } account.isAdmin = true } + +export const approveAccountRequestByIdentityPoolId = async identityPoolId => { + await resolveAfter(1500) + + const accountIndex = mockPendingRequestAccounts.findIndex( + account => account.identityPoolId === identityPoolId, + ) + if (accountIndex === -1) { + throw new Error('Account not found!') + } + if (mockPendingRequestAccounts[accountIndex].identityPoolId.endsWith('10')) { + throw new Error('Something weird happened!') + } + mockPendingRequestAccounts.splice(accountIndex, 1) +} + +export const denyAccountRequestByIdentityPoolId = async identityPoolId => { + await resolveAfter(1500) + + const accountIndex = mockPendingRequestAccounts.findIndex( + account => account.identityPoolId === identityPoolId, + ) + if (accountIndex === -1) { + throw new Error('Account not found!') + } + if (mockPendingRequestAccounts[accountIndex].identityPoolId.endsWith('10')) { + throw new Error('Something weird happened!') + } + mockPendingRequestAccounts.splice(accountIndex, 1) +} From 0f75825b5576a9af01e07ed1b7868583af0ef94b Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Mon, 19 Aug 2019 12:59:00 -0700 Subject: [PATCH 23/45] PendingRequests: fix wording in Deny modal --- dev-portal/src/pages/Admin/Accounts/PendingRequests.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dev-portal/src/pages/Admin/Accounts/PendingRequests.jsx b/dev-portal/src/pages/Admin/Accounts/PendingRequests.jsx index c610d1fe4..cf738ff84 100644 --- a/dev-portal/src/pages/Admin/Accounts/PendingRequests.jsx +++ b/dev-portal/src/pages/Admin/Accounts/PendingRequests.jsx @@ -127,13 +127,13 @@ const DenyAccountModal = React.memo(

Are you sure you want to deny this account request? The request will be permanently deleted, and {account.emailAddress}{' '} - will need to sign up again to request an account. + will need to sign up again in order to request an account.

From 263ffec95f38dd0480db551941b7b3214cb3298a Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Mon, 19 Aug 2019 15:51:42 -0700 Subject: [PATCH 24/45] PendingRequests: fix missing account loading after denying an account --- .../pages/Admin/Accounts/PendingRequests.jsx | 1 + .../Accounts/__tests__/PendingRequests.jsx | 78 +++++++++++++++++++ dev-portal/src/services/accounts.js | 23 +++--- 3 files changed, 90 insertions(+), 12 deletions(-) diff --git a/dev-portal/src/pages/Admin/Accounts/PendingRequests.jsx b/dev-portal/src/pages/Admin/Accounts/PendingRequests.jsx index cf738ff84..daf0f42dc 100644 --- a/dev-portal/src/pages/Admin/Accounts/PendingRequests.jsx +++ b/dev-portal/src/pages/Admin/Accounts/PendingRequests.jsx @@ -60,6 +60,7 @@ const PendingRequests = () => { sendMessage(dismiss => ( )) + await refreshAccounts() } catch (error) { sendMessage(dismiss => ( { .map(el => el.textContent), ).toEqual(['90@example.com']) }) + + it('denies multiple accounts', async () => { + const deletedEmails = [] + AccountService.fetchPendingRequestAccounts = jest + .fn() + .mockImplementation(() => + Promise.resolve( + MOCK_ACCOUNTS.filter( + account => + !deletedEmails.some( + deletedEmail => account.emailAddress === deletedEmail, + ), + ), + ), + ) + AccountService.denyAccountRequestByIdentityPoolId = jest + .fn() + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + + const page = renderPage() + await accountsTestUtils.waitForAccountsToLoad(page) + const table = page.getByTestId(AccountsTable.ACCOUNTS_TABLE_TESTID) + + // Select and delete 3@example.com + const targetCell1 = rtl.getByText(table, '3@example.com') + rtl.fireEvent.click(targetCell1) + + const denyButton = page.getByText('Deny') + expect(denyButton.disabled).toEqual(false) + rtl.fireEvent.click(denyButton) + + let modal = rtl + .getByText(document, 'Confirm request denial') + .closest('.modal') + let confirmDenyButton = rtl.getByText(modal, 'Deny') + deletedEmails.push('3@example.com') + rtl.fireEvent.click(confirmDenyButton) + + await accountsTestUtils.waitForAccountsToLoad(page) + accountsTestUtils.expectEmailIn('0@example.com', table) + expect( + accountsTestUtils.queryByColumnText( + table, + 'emailAddress', + '3@example.com', + ), + ).toBeNull() + + // Select and delete 4@example.com + expect(denyButton.disabled).toEqual(true) + const targetCell2 = rtl.getByText(table, '4@example.com') + rtl.fireEvent.click(targetCell2) + expect(denyButton.disabled).toEqual(false) + rtl.fireEvent.click(denyButton) + + modal = rtl.getByText(document, 'Confirm request denial').closest('.modal') + await rtl.wait(() => rtl.getByText(modal, /4@example\.com/)) + deletedEmails.push('4@example.com') + confirmDenyButton = rtl.getByText(modal, 'Deny') + rtl.fireEvent.click(confirmDenyButton) + + await accountsTestUtils.waitForAccountsToLoad(page) + expect( + accountsTestUtils.queryByColumnText( + table, + 'emailAddress', + '3@example.com', + ), + ).toBeNull() + expect( + accountsTestUtils.queryByColumnText( + table, + 'emailAddress', + '4@example.com', + ), + ).toBeNull() + }) }) const NUM_MOCK_ACCOUNTS = 157 // should be prime diff --git a/dev-portal/src/services/accounts.js b/dev-portal/src/services/accounts.js index e2daeec0a..435535bac 100644 --- a/dev-portal/src/services/accounts.js +++ b/dev-portal/src/services/accounts.js @@ -84,29 +84,28 @@ export const promoteAccountByIdentityPoolId = async identityPoolId => { export const approveAccountRequestByIdentityPoolId = async identityPoolId => { await resolveAfter(1500) - const accountIndex = mockPendingRequestAccounts.findIndex( - account => account.identityPoolId === identityPoolId, - ) - if (accountIndex === -1) { + if (!mockPendingRequestAccounts.some(matchingIdentityId(identityPoolId))) { throw new Error('Account not found!') } - if (mockPendingRequestAccounts[accountIndex].identityPoolId.endsWith('10')) { + if (identityPoolId.endsWith('10')) { throw new Error('Something weird happened!') } - mockPendingRequestAccounts.splice(accountIndex, 1) + + _.remove(mockPendingRequestAccounts, matchingIdentityId(identityPoolId)) } export const denyAccountRequestByIdentityPoolId = async identityPoolId => { await resolveAfter(1500) - const accountIndex = mockPendingRequestAccounts.findIndex( - account => account.identityPoolId === identityPoolId, - ) - if (accountIndex === -1) { + if (!mockPendingRequestAccounts.some(matchingIdentityId(identityPoolId))) { throw new Error('Account not found!') } - if (mockPendingRequestAccounts[accountIndex].identityPoolId.endsWith('10')) { + if (identityPoolId.endsWith('10')) { throw new Error('Something weird happened!') } - mockPendingRequestAccounts.splice(accountIndex, 1) + + _.remove(mockPendingRequestAccounts, matchingIdentityId(identityPoolId)) } + +const matchingIdentityId = targetId => account => + account.identityPoolId === targetId From 885dec90df37cb3d820013202dc85575f9e441e8 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Tue, 20 Aug 2019 15:32:20 -0700 Subject: [PATCH 25/45] Add PendingInvites UI --- .../Admin/Accounts/AccountsTableColumns.jsx | 24 + .../pages/Admin/Accounts/AccountInvites.jsx | 7 - .../pages/Admin/Accounts/PendingInvites.jsx | 244 ++++++++++ .../Accounts/__tests__/PendingInvites.jsx | 439 ++++++++++++++++++ dev-portal/src/pages/Admin/Admin.jsx | 4 +- dev-portal/src/services/accounts.js | 49 ++ dev-portal/src/utils/use-boolean.jsx | 22 + 7 files changed, 780 insertions(+), 9 deletions(-) delete mode 100644 dev-portal/src/pages/Admin/Accounts/AccountInvites.jsx create mode 100644 dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx create mode 100644 dev-portal/src/pages/Admin/Accounts/__tests__/PendingInvites.jsx create mode 100644 dev-portal/src/utils/use-boolean.jsx diff --git a/dev-portal/src/components/Admin/Accounts/AccountsTableColumns.jsx b/dev-portal/src/components/Admin/Accounts/AccountsTableColumns.jsx index 4e3f05842..6099e696e 100644 --- a/dev-portal/src/components/Admin/Accounts/AccountsTableColumns.jsx +++ b/dev-portal/src/components/Admin/Accounts/AccountsTableColumns.jsx @@ -73,6 +73,21 @@ export const Promoter = { }, } +export const Inviter = { + id: 'inviter', + title: 'Inviter', + render: ({ inviterIdentityPoolId, inviterEmailAddress }) => + inviterIdentityPoolId + ? `${inviterEmailAddress} (${inviterIdentityPoolId})` + : '', + filtering: { + accessor: ({ inviterIdentityPoolId, inviterEmailAddress }) => + inviterIdentityPoolId + ? `${inviterEmailAddress} ${inviterIdentityPoolId}` + : '', + }, +} + export const DatePromoted = { id: 'datePromoted', title: 'Date promoted', @@ -91,6 +106,15 @@ export const DateRequested = { }, } +export const DateInvited = { + id: 'dateInvited', + title: 'Date invited', + render: account => formatDate(account.dateInvited), + ordering: { + iteratee: 'dateInvited', + }, +} + const DATE_TIME_FORMATTER = new Intl.DateTimeFormat('default', { year: 'numeric', month: 'numeric', diff --git a/dev-portal/src/pages/Admin/Accounts/AccountInvites.jsx b/dev-portal/src/pages/Admin/Accounts/AccountInvites.jsx deleted file mode 100644 index 98209e757..000000000 --- a/dev-portal/src/pages/Admin/Accounts/AccountInvites.jsx +++ /dev/null @@ -1,7 +0,0 @@ -import React, { Component } from 'react' - -export default class AccountInvites extends Component { - render = () => { - return

TODO: Account invites

- } -} diff --git a/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx b/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx new file mode 100644 index 000000000..4f9a233f3 --- /dev/null +++ b/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx @@ -0,0 +1,244 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { + Button, + Container, + Header, + Input, + Message, + Modal, +} from 'semantic-ui-react' + +import * as MessageList from 'components/MessageList' +import * as AccountService from 'services/accounts' +import * as AccountsTable from 'components/Admin/Accounts/AccountsTable' +import * as AccountsTableColumns from 'components/Admin/Accounts/AccountsTableColumns' + +import { useBoolean } from 'utils/use-boolean' + +const PendingInvites = () => { + const [accounts, setAccounts] = useState([]) + const [loading, setLoading] = useState(true) + const [selectedAccount, setSelectedAccount] = useState(undefined) + const [isCreateModalOpen, openCreateModal, closeCreateModal] = useBoolean( + false, + ) + const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useBoolean( + false, + ) + const [messages, sendMessage] = MessageList.useMessages() + + const refreshAccounts = () => + AccountService.fetchPendingInviteAccounts().then(accounts => + setAccounts(accounts), + ) + + // Initial load + useEffect(() => { + refreshAccounts().finally(() => setLoading(false)) + }, []) + + const onSelectAccount = useCallback( + account => setSelectedAccount(account), + [], + ) + + const onConfirmCreate = useCallback( + async emailAddress => { + setLoading(true) + closeCreateModal() + try { + await AccountService.createInviteByEmail(emailAddress) + sendMessage(dismiss => ( + + )) + await refreshAccounts() + } catch (error) { + sendMessage(dismiss => ( + + )) + } finally { + setLoading(false) + } + }, + [sendMessage, closeCreateModal], + ) + + const onConfirmDelete = useCallback(async () => { + setLoading(true) + closeDeleteModal() + try { + await AccountService.deleteInviteByIdentityPoolId( + selectedAccount.identityPoolId, + ) + sendMessage(dismiss => ( + + )) + await refreshAccounts() + } catch (error) { + sendMessage(dismiss => ( + + )) + } finally { + setLoading(false) + } + }, [sendMessage, selectedAccount, closeDeleteModal]) + + return ( + +
Pending invites
+ + + + + + +
+ ) +} +export default PendingInvites + +const TableActions = React.memo( + ({ canCreate, onClickCreate, canDelete, onClickDelete }) => ( + + + + + + ) +} + +const DeleteInviteModal = React.memo( + ({ account, onConfirm, open, onClose }) => + account && ( + + Confirm invite deletion + +

+ Are you sure you want to delete this account invite for{' '} + {account.emailAddress}? This action is + irreversible. +

+
+ + + + +
+ ), +) + +const CreateSuccessMessage = React.memo(({ emailAddress, dismiss }) => ( + + + Sent account invite to {emailAddress}. + + +)) + +const CreateFailureMessage = React.memo( + ({ emailAddress, errorMessage, dismiss }) => ( + + +

+ Failed to send account invite to {emailAddress}. +

+ {errorMessage &&

Error message: {errorMessage}

} +
+
+ ), +) + +const DeleteSuccessMessage = React.memo(({ account, dismiss }) => ( + + + Deleted account invite for {account.emailAddress}. + + +)) + +const DeleteFailureMessage = React.memo( + ({ account, errorMessage, dismiss }) => ( + + +

+ Failed to delete account invite for{' '} + {account.emailAddress}. +

+ {errorMessage &&

Error message: {errorMessage}

} +
+
+ ), +) diff --git a/dev-portal/src/pages/Admin/Accounts/__tests__/PendingInvites.jsx b/dev-portal/src/pages/Admin/Accounts/__tests__/PendingInvites.jsx new file mode 100644 index 000000000..e4204fee1 --- /dev/null +++ b/dev-portal/src/pages/Admin/Accounts/__tests__/PendingInvites.jsx @@ -0,0 +1,439 @@ +import _ from 'lodash' +import React from 'react' +import * as rtl from '@testing-library/react' +import '@testing-library/jest-dom/extend-expect' + +import * as testUtils from 'utils/test-utils' +import * as accountsTestUtils from 'utils/accounts-test-utils' + +import PendingInvites from 'pages/Admin/Accounts/PendingInvites' +import * as AccountsTable from 'components/Admin/Accounts/AccountsTable' +import * as AccountService from 'services/accounts' + +jest.mock('services/accounts') + +//: remove when React 16.9 is released +testUtils.suppressReact16Dot8ActWarningsGlobally() + +afterEach(rtl.cleanup) + +const renderPage = () => testUtils.renderWithRouter() + +describe('PendingInvites page', () => { + it('renders', async () => { + AccountService.fetchPendingInviteAccounts = jest.fn().mockResolvedValue([]) + const page = renderPage() + expect(page.baseElement).toBeTruthy() + }) + + it('initially shows the loading state', async () => { + AccountService.fetchPendingInviteAccounts = jest + .fn() + .mockReturnValue(new Promise(() => {})) + + const page = renderPage() + expect( + page.queryAllByTestId(AccountsTable.ACCOUNT_ROW_PLACEHOLDER_TESTID), + ).not.toHaveLength(0) + }) + + it('shows the accounts after loading', async () => { + AccountService.fetchPendingInviteAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ACCOUNTS) + const page = renderPage() + await accountsTestUtils.waitForAccountsToLoad(page) + + _.take(MOCK_ACCOUNTS, AccountsTable.DEFAULT_PAGE_SIZE).forEach( + ({ emailAddress }) => + accountsTestUtils.expectEmailIn(emailAddress, page.baseElement), + ) + }) + + it('orders pages for all accounts', async () => { + AccountService.fetchPendingInviteAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ACCOUNTS) + + const page = renderPage() + await accountsTestUtils.waitForAccountsToLoad(page) + const pagination = page.getByRole('navigation') + + const page1Button = rtl.queryByText(pagination, '1') + expect(page1Button).not.toBeNull() + + const page16Button = rtl.queryByText(pagination, '16') + expect(page16Button).not.toBeNull() + rtl.fireEvent.click(page16Button) + accountsTestUtils.expectEmailIn('150@example.com', page.baseElement) + }) + + it('orders accounts by email address', async () => { + AccountService.fetchPendingInviteAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ACCOUNTS) + + const page = renderPage() + await accountsTestUtils.waitForAccountsToLoad(page) + + // Order ascending + const table = page.getByTestId(AccountsTable.ACCOUNTS_TABLE_TESTID) + const emailAddressHeader = rtl.getByText(table, 'Email address') + rtl.fireEvent.click(emailAddressHeader) + + // Check that first page is correct + _(MOCK_ACCOUNTS) + .orderBy(['emailAddress']) + .take(AccountsTable.DEFAULT_PAGE_SIZE) + .forEach(({ emailAddress }) => + accountsTestUtils.expectEmailIn(emailAddress, table), + ) + + // Check that last page is correct + const pagination = page.getByRole('navigation') + const lastPageButton = rtl.getByLabelText(pagination, 'Last item') + rtl.fireEvent.click(lastPageButton) + _(MOCK_ACCOUNTS) + .orderBy(['emailAddress']) + .drop( + Math.floor(MOCK_ACCOUNTS.length / AccountsTable.DEFAULT_PAGE_SIZE) * + AccountsTable.DEFAULT_PAGE_SIZE, + ) + .forEach(({ emailAddress }) => + accountsTestUtils.expectEmailIn(emailAddress, table), + ) + + // Order descending, go back to first page + rtl.fireEvent.click(emailAddressHeader) + const firstPageButton = rtl.getByLabelText(pagination, 'First item') + rtl.fireEvent.click(firstPageButton) + + // Check that first page is correct + _(MOCK_ACCOUNTS) + .orderBy(['emailAddress'], ['desc']) + .take(AccountsTable.DEFAULT_PAGE_SIZE) + .forEach(({ emailAddress }) => + accountsTestUtils.expectEmailIn(emailAddress, table), + ) + }) + + it('orders accounts by date invited', async () => { + AccountService.fetchPendingInviteAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ACCOUNTS) + + const page = renderPage() + await accountsTestUtils.waitForAccountsToLoad(page) + + // Order ascending + const table = page.getByTestId(AccountsTable.ACCOUNTS_TABLE_TESTID) + const dateInvitedHeader = rtl.getByText(table, 'Date invited') + rtl.fireEvent.click(dateInvitedHeader) + + // Check that first page is correct + _(MOCK_ACCOUNTS) + .orderBy(['dateInvited'], ['asc']) + .take(AccountsTable.DEFAULT_PAGE_SIZE) + .forEach(({ emailAddress }) => + accountsTestUtils.expectEmailIn(emailAddress, table), + ) + }) + + it('filters accounts by email address', async () => { + AccountService.fetchPendingInviteAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ACCOUNTS) + + const page = renderPage() + await accountsTestUtils.waitForAccountsToLoad(page) + const filterInput = page.getByPlaceholderText('Search by...') + const table = page.getByTestId(AccountsTable.ACCOUNTS_TABLE_TESTID) + + rtl.fireEvent.change(filterInput, { target: { value: '1' } }) + _(MOCK_ACCOUNTS) + .filter(({ emailAddress }) => emailAddress.includes('1')) + .take(AccountsTable.DEFAULT_PAGE_SIZE) + .forEach(({ emailAddress }) => + accountsTestUtils.expectEmailIn(emailAddress, table), + ) + + rtl.fireEvent.change(filterInput, { target: { value: '90' } }) + expect( + accountsTestUtils + .queryAllByColumnText(table, 'emailAddress', /@example\.com/) + .map(el => el.textContent), + ).toEqual(['90@example.com']) + }) + + it('filters accounts by inviter email address', async () => { + AccountService.fetchPendingInviteAccounts = jest + .fn() + .mockResolvedValueOnce(MOCK_ACCOUNTS) + + const page = renderPage() + await accountsTestUtils.waitForAccountsToLoad(page) + const filterInput = page.getByPlaceholderText('Search by...') + const filterDropdown = page.getByTestId( + AccountsTable.FILTER_DROPDOWN_TESTID, + ) + const table = page.getByTestId(AccountsTable.ACCOUNTS_TABLE_TESTID) + + rtl.fireEvent.click(filterDropdown) + const filterByInviterOption = rtl.getByText(filterDropdown, 'Inviter') + rtl.fireEvent.click(filterByInviterOption) + + rtl.fireEvent.change(filterInput, { target: { value: '20@example.com' } }) + _(MOCK_ACCOUNTS) + .filter({ inviterEmailAddress: '20@example.com' }) + .take(AccountsTable.DEFAULT_PAGE_SIZE) + .forEach(({ emailAddress }) => + accountsTestUtils.expectEmailIn(emailAddress, table), + ) + + rtl.fireEvent.change(filterInput, { target: { value: '30@example.com' } }) + expect( + accountsTestUtils.queryAllByColumnText( + table, + 'emailAddress', + /@example\.com/, + ), + ).toHaveLength(0) + }) + + it('creates an invite', async () => { + const createdAccounts = [] + AccountService.fetchPendingInviteAccounts = jest + .fn() + .mockImplementation(() => + Promise.resolve([...MOCK_ACCOUNTS, ...createdAccounts]), + ) + AccountService.createInviteByEmail = jest + .fn() + .mockImplementation(emailAddress => { + createdAccounts.push({ + identityPoolId: `createedIdentityId${createdAccounts.length}`, + userPoolId: `createdUserId${createdAccounts.length}`, + emailAddress: emailAddress, + dateInvited: new Date(), + inviterEmailAddress: 'you@example.com', + inviterIdentityPoolId: 'me', + }) + }) + + const page = renderPage() + await accountsTestUtils.waitForAccountsToLoad(page) + + const startCreateButton = page.getByText(/Create invite/) + rtl.fireEvent.click(startCreateButton) + const createModal = await rtl.waitForElement(() => + rtl.getByText(document, 'Create invite').closest('.modal'), + ) + + const emailInput = rtl.getByPlaceholderText(createModal, 'Email address') + const confirmCreateButton = rtl + .getAllByRole(createModal, 'button') + .filter(el => el.textContent === 'Create')[0] + expect(confirmCreateButton).toBeInTheDocument() + + expect(confirmCreateButton.disabled).toBe(true) + rtl.fireEvent.change(emailInput, { target: { value: '000' } }) + expect(confirmCreateButton.disabled).toBe(true) + rtl.fireEvent.change(emailInput, { target: { value: '000@' } }) + expect(confirmCreateButton.disabled).toBe(true) + rtl.fireEvent.change(emailInput, { + target: { value: '000@example.com' }, + }) + expect(confirmCreateButton.disabled).toBe(false) + rtl.fireEvent.click(confirmCreateButton) + + await accountsTestUtils.waitForAccountsToLoad(page) + expect( + page.getAllByText( + (_content, element) => + element.textContent === 'Sent account invite to 000@example.com.', + )[0], + ).toBeInTheDocument() + + const filterInput = page.getByPlaceholderText('Search by...') + const table = page.getByTestId(AccountsTable.ACCOUNTS_TABLE_TESTID) + rtl.fireEvent.change(filterInput, { target: { value: '000' } }) + accountsTestUtils.expectEmailIn('000@example.com', table) + }) + + it('shows a message when creation fails', async () => { + AccountService.fetchPendingInviteAccounts = jest.fn().mockResolvedValue([]) + AccountService.createInviteByEmail = jest + .fn() + .mockRejectedValue(new Error('You must construct additional pylons')) + + const page = renderPage() + await accountsTestUtils.waitForAccountsToLoad(page) + + const startCreateButton = page.getByText(/Create invite/) + rtl.fireEvent.click(startCreateButton) + const createModal = await rtl.waitForElement(() => + rtl.getByText(document, 'Create invite').closest('.modal'), + ) + + const emailInput = rtl.getByPlaceholderText(createModal, 'Email address') + const confirmCreateButton = rtl + .getAllByRole(createModal, 'button') + .filter(el => el.textContent === 'Create')[0] + expect(confirmCreateButton).toBeInTheDocument() + rtl.fireEvent.change(emailInput, { + target: { value: '000@example.com' }, + }) + rtl.fireEvent.click(confirmCreateButton) + + await accountsTestUtils.waitForAccountsToLoad(page) + page.getAllByText((_content, element) => + element.textContent.startsWith( + 'Failed to send account invite to 000@example.com', + ), + ) + page.getAllByText((_content, element) => + element.textContent.startsWith( + 'Error message: You must construct additional pylons', + ), + ) + }) + + it('deletes an invite', async () => { + const deletedIdentityIds = [] + AccountService.fetchPendingInviteAccounts = jest + .fn() + .mockImplementation(() => + Promise.resolve( + MOCK_ACCOUNTS.filter( + ({ identityPoolId }) => + !deletedIdentityIds.includes(identityPoolId), + ), + ), + ) + AccountService.deleteInviteByIdentityPoolId = jest + .fn() + .mockImplementation(identityPoolId => { + deletedIdentityIds.push(identityPoolId) + }) + + const page = renderPage() + await accountsTestUtils.waitForAccountsToLoad(page) + + const startDeleteButton = page.getByText('Delete') + expect(startDeleteButton.disabled).toBe(true) + + const targetAccountEmailCell = accountsTestUtils.queryByColumnText( + page.baseElement, + 'emailAddress', + '5@example.com', + ) + expect(targetAccountEmailCell).toBeInTheDocument() + rtl.fireEvent.click(targetAccountEmailCell) + expect(startDeleteButton.disabled).toBe(false) + + rtl.fireEvent.click(startDeleteButton) + const deleteModal = await rtl.waitForElement(() => + rtl.getByText(document, 'Confirm invite deletion').closest('.modal'), + ) + rtl.queryAllByText(deleteModal, (_content, element) => + element.textContent.startsWith( + 'Are you sure you want to delete this account invite for 5@example.com?', + ), + ) + + const confirmDeleteButton = rtl + .getAllByRole(deleteModal, 'button') + .filter(el => el.textContent === 'Delete')[0] + expect(confirmDeleteButton).toBeInTheDocument() + rtl.fireEvent.click(confirmDeleteButton) + + await accountsTestUtils.waitForAccountsToLoad(page) + expect( + page.getAllByText( + (_content, element) => + element.textContent === 'Deleted account invite for 5@example.com.', + )[0], + ).toBeInTheDocument() + + const filterInput = page.getByPlaceholderText('Search by...') + const table = page.getByTestId(AccountsTable.ACCOUNTS_TABLE_TESTID) + rtl.fireEvent.change(filterInput, { target: { value: '5@' } }) + expect( + accountsTestUtils.queryByColumnText( + table, + 'emailAddress', + '5@example.com', + ), + ).toBeNull() + }) + + it('shows a message when deletion fails', async () => { + AccountService.fetchPendingInviteAccounts = jest + .fn() + .mockResolvedValue(MOCK_ACCOUNTS) + AccountService.deleteInviteByIdentityPoolId = jest + .fn() + .mockRejectedValue(new Error('Target lost.')) + + const page = renderPage() + await accountsTestUtils.waitForAccountsToLoad(page) + + const targetAccountEmailCell = accountsTestUtils.queryByColumnText( + page.baseElement, + 'emailAddress', + '2@example.com', + ) + expect(targetAccountEmailCell).toBeInTheDocument() + rtl.fireEvent.click(targetAccountEmailCell) + + const startDeleteButton = page.getByText('Delete') + rtl.fireEvent.click(startDeleteButton) + const createModal = await rtl.waitForElement(() => + rtl.getByText(document, 'Confirm invite deletion').closest('.modal'), + ) + const confirmDeleteButton = rtl + .getAllByRole(createModal, 'button') + .filter(el => el.textContent === 'Delete')[0] + expect(confirmDeleteButton).toBeInTheDocument() + rtl.fireEvent.click(confirmDeleteButton) + + await accountsTestUtils.waitForAccountsToLoad(page) + page.getAllByText((_content, element) => + element.textContent.startsWith( + 'Failed to delete account invite for 2@example.com', + ), + ) + page.getAllByText((_content, element) => + element.textContent.startsWith('Error message: Target lost.'), + ) + }) +}) + +const NUM_MOCK_ACCOUNTS = 157 // should be prime + +const MOCK_INVITERS = _.range(NUM_MOCK_ACCOUNTS).map(index => { + if (_.inRange(index, 20, 90)) { + return 10 + } else if (_.inRange(index, 90, 120)) { + return 20 + } else if (_.inRange(index, 120, NUM_MOCK_ACCOUNTS)) { + return 100 + } + return null +}) + +const MOCK_DATES_INVITED = (() => { + const now = Date.now() + return _.range(NUM_MOCK_ACCOUNTS).map( + index => new Date(now + ((index * 3) % NUM_MOCK_ACCOUNTS) * 1000), + ) +})() + +const MOCK_ACCOUNTS = MOCK_INVITERS.map((inviter, index) => ({ + identityPoolId: `identityPoolId${index}`, + userPoolId: `userPoolId${index}`, + emailAddress: `${index}@example.com`, + dateInvited: MOCK_DATES_INVITED[index], + inviterEmailAddress: inviter && `${inviter}@example.com`, + inviterIdentityPoolId: inviter && `identityPoolId${inviter}`, +})) diff --git a/dev-portal/src/pages/Admin/Admin.jsx b/dev-portal/src/pages/Admin/Admin.jsx index 47caa5183..4c41a0625 100644 --- a/dev-portal/src/pages/Admin/Admin.jsx +++ b/dev-portal/src/pages/Admin/Admin.jsx @@ -6,7 +6,7 @@ import { AdminRoute } from 'index' import RegisteredAccounts from 'pages/Admin/Accounts/RegisteredAccounts' import AdminAccounts from 'pages/Admin/Accounts/AdminAccounts' -import AccountInvites from 'pages/Admin/Accounts/AccountInvites' +import PendingInvites from 'pages/Admin/Accounts/PendingInvites' import PendingRequests from 'pages/Admin/Accounts/PendingRequests' export class Admin extends Component { @@ -20,7 +20,7 @@ export class Admin extends Component { - + diff --git a/dev-portal/src/services/accounts.js b/dev-portal/src/services/accounts.js index 435535bac..38d701499 100644 --- a/dev-portal/src/services/accounts.js +++ b/dev-portal/src/services/accounts.js @@ -7,13 +7,17 @@ const mockData = (() => { const now = Date.now() const adminStep = 10 return Array.from({ length: NUM_MOCK_ACCOUNTS }).map((_value, index) => { + let inviter = 1 let promoter = null if (_.inRange(index, 20, 90)) { promoter = 10 + inviter = 10 } else if (_.inRange(index, 90, 120)) { promoter = 20 + inviter = 20 } else if (_.inRange(index, 120, NUM_MOCK_ACCOUNTS)) { promoter = 100 + inviter = 100 } return { @@ -25,6 +29,8 @@ const mockData = (() => { new Date(now + ((index * 3) % NUM_MOCK_ACCOUNTS) * 1000).toJSON(), promoterEmailAddress: promoter && `${promoter}@example.com`, promoterIdentityPoolId: promoter && `identityPoolId${promoter}`, + inviterEmailAddress: inviter && `${inviter}@example.com`, + inviterIdentityPoolId: inviter && `identityPoolId${inviter}`, dateRegistered: new Date( now + ((index * 3) % NUM_MOCK_ACCOUNTS) * 1000, ).toJSON(), @@ -39,6 +45,13 @@ const mockPendingRequestAccounts = _.cloneDeep(mockData).map( ({ dateRegistered, ...rest }) => ({ ...rest, dateRequested: dateRegistered }), ) +const mockPendingInviteAccounts = _.cloneDeep(mockData).map( + ({ dateRegistered, ...rest }) => ({ + ...rest, + dateInvited: dateRegistered, + }), +) + export const fetchRegisteredAccounts = () => { return resolveAfter(1500, mockData.slice()) } @@ -51,6 +64,10 @@ export const fetchPendingRequestAccounts = () => { return resolveAfter(1500, mockPendingRequestAccounts.slice()) } +export const fetchPendingInviteAccounts = () => { + return resolveAfter(1500, mockPendingInviteAccounts.slice()) +} + export const deleteAccountByIdentityPoolId = async identityPoolId => { await resolveAfter(1500) @@ -66,6 +83,38 @@ export const deleteAccountByIdentityPoolId = async identityPoolId => { mockData.splice(accountIndex, 1) } +export const deleteInviteByIdentityPoolId = async identityPoolId => { + await resolveAfter(1500) + + const accountIndex = mockPendingInviteAccounts.findIndex( + account => account.identityPoolId === identityPoolId, + ) + if (accountIndex === -1) { + throw new Error('Account not found!') + } + if (identityPoolId.endsWith('10')) { + throw new Error('Something weird happened!') + } + mockPendingInviteAccounts.splice(accountIndex, 1) +} + +export const createInviteByEmail = async emailAddress => { + await resolveAfter(1500) + + const account = { + identityPoolId: `temp`, + userPoolId: `temp`, + emailAddress, + dateInvited: new Date(Date.now()).toJSON(), + inviterEmailAddress: `you@localhost`, + inviterIdentityPoolId: `yourIdentityId`, + apiKeyId: `temp`, + registrationMethod: `invite`, + } + + mockPendingInviteAccounts.push(account) +} + export const promoteAccountByIdentityPoolId = async identityPoolId => { await resolveAfter(1500) diff --git a/dev-portal/src/utils/use-boolean.jsx b/dev-portal/src/utils/use-boolean.jsx new file mode 100644 index 000000000..f745c948a --- /dev/null +++ b/dev-portal/src/utils/use-boolean.jsx @@ -0,0 +1,22 @@ +import { useState, useCallback } from 'react' + +/** + * A React state hook wrapping a boolean value, returning `setTrue` and + * `setFalse` functions which do as their names suggest. Returns `[state, + * setTrue, setFalse, setState]`, in which the first and last functions + * correspond to those returned by `useState`, and in which all functions (i.e. + * all but `state`) are stable. + * + * This is especially useful for controlled modals, for example, which may + * close themselves via a callback: + * ```javascript + * const [isOpen, open, close] = useBoolean(false) + * return ( ... ) + * ``` + */ +export const useBoolean = initialState => { + const [state, setState] = useState(initialState) + const setTrue = useCallback(() => setState(true), []) + const setFalse = useCallback(() => setState(false), []) + return [state, setTrue, setFalse, setState] +} From 334bb0b64cafbef70b4219fbea380c39119ecaf1 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Mon, 26 Aug 2019 15:21:41 -0700 Subject: [PATCH 26/45] PendingInvites: change some Create modal wording --- dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx b/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx index 4f9a233f3..8ae1d3a65 100644 --- a/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx +++ b/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx @@ -160,7 +160,7 @@ const CreateInviteModal = ({ onConfirm, open, onClose }) => {

Enter an email address below and select Create to - send an account invite. + send an invitation to create an account.

Date: Mon, 26 Aug 2019 15:22:12 -0700 Subject: [PATCH 27/45] PendingInvites: show "Please enter a valid email address" in Create modal --- .../pages/Admin/Accounts/PendingInvites.jsx | 6 +- .../Accounts/__tests__/PendingInvites.jsx | 55 +++++++++++++++++-- 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx b/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx index 8ae1d3a65..501035f06 100644 --- a/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx +++ b/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx @@ -155,17 +155,21 @@ const CreateInviteModal = ({ onConfirm, open, onClose }) => { }, [onConfirm, email]) return ( - + Create invite

Enter an email address below and select Create to send an invitation to create an account.

+
diff --git a/dev-portal/src/pages/Admin/Accounts/__tests__/PendingInvites.jsx b/dev-portal/src/pages/Admin/Accounts/__tests__/PendingInvites.jsx index e4204fee1..503a8eff8 100644 --- a/dev-portal/src/pages/Admin/Accounts/__tests__/PendingInvites.jsx +++ b/dev-portal/src/pages/Admin/Accounts/__tests__/PendingInvites.jsx @@ -235,15 +235,9 @@ describe('PendingInvites page', () => { .filter(el => el.textContent === 'Create')[0] expect(confirmCreateButton).toBeInTheDocument() - expect(confirmCreateButton.disabled).toBe(true) - rtl.fireEvent.change(emailInput, { target: { value: '000' } }) - expect(confirmCreateButton.disabled).toBe(true) - rtl.fireEvent.change(emailInput, { target: { value: '000@' } }) - expect(confirmCreateButton.disabled).toBe(true) rtl.fireEvent.change(emailInput, { target: { value: '000@example.com' }, }) - expect(confirmCreateButton.disabled).toBe(false) rtl.fireEvent.click(confirmCreateButton) await accountsTestUtils.waitForAccountsToLoad(page) @@ -260,6 +254,55 @@ describe('PendingInvites page', () => { accountsTestUtils.expectEmailIn('000@example.com', table) }) + it('prevents creating an invite for an invalid email address', async () => { + AccountService.fetchPendingInviteAccounts = jest.fn().mockResolvedValue([]) + + const page = renderPage() + await accountsTestUtils.waitForAccountsToLoad(page) + + const startCreateButton = page.getByText(/Create invite/) + rtl.fireEvent.click(startCreateButton) + const createModal = await rtl.waitForElement(() => + rtl.getByText(document, 'Create invite').closest('.modal'), + ) + + const emailInput = rtl.getByPlaceholderText(createModal, 'Email address') + const confirmCreateButton = rtl + .getAllByRole(createModal, 'button') + .filter(el => el.textContent === 'Create')[0] + expect(confirmCreateButton).toBeInTheDocument() + + const pleaseEnterAValidEmail = rtl.queryByText( + createModal, + 'Please enter a valid email address.', + ) + const pleaseEnterAValidEmailIsVisible = () => + !pleaseEnterAValidEmail.classList.contains('hidden') + + expect(confirmCreateButton.disabled).toBe(true) + expect(pleaseEnterAValidEmailIsVisible()).toBe(true) + + rtl.fireEvent.change(emailInput, { target: { value: '000' } }) + expect(confirmCreateButton.disabled).toBe(true) + expect(pleaseEnterAValidEmailIsVisible()).toBe(true) + + rtl.fireEvent.change(emailInput, { target: { value: '000@' } }) + expect(confirmCreateButton.disabled).toBe(true) + expect(pleaseEnterAValidEmailIsVisible()).toBe(true) + + rtl.fireEvent.change(emailInput, { target: { value: '000@example.com' } }) + expect(confirmCreateButton.disabled).toBe(false) + expect(pleaseEnterAValidEmailIsVisible()).toBe(false) + + rtl.fireEvent.change(emailInput, { target: { value: '000' } }) + expect(confirmCreateButton.disabled).toBe(true) + expect(pleaseEnterAValidEmailIsVisible()).toBe(true) + + rtl.fireEvent.change(emailInput, { target: { value: '' } }) + expect(confirmCreateButton.disabled).toBe(true) + expect(pleaseEnterAValidEmailIsVisible()).toBe(true) + }) + it('shows a message when creation fails', async () => { AccountService.fetchPendingInviteAccounts = jest.fn().mockResolvedValue([]) AccountService.createInviteByEmail = jest From 74b505c12a962a61e0092ad249ec31719b19e1c0 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Tue, 27 Aug 2019 08:53:06 -0700 Subject: [PATCH 28/45] MessageList: refactor to avoid components in state --- dev-portal/src/components/MessageList.jsx | 33 ++++++++++++++++------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/dev-portal/src/components/MessageList.jsx b/dev-portal/src/components/MessageList.jsx index bbcacb20b..b5cb80dd5 100644 --- a/dev-portal/src/components/MessageList.jsx +++ b/dev-portal/src/components/MessageList.jsx @@ -1,14 +1,15 @@ import React, { useState } from 'react' export const MessageList = ({ messages }) => - messages.map((message, index) => ( - {message} + messages.map(({ render, id }) => ( + {render()} )) /** * A Hook for operating a list of "messages" which should be self-dismissable. * Returns `[messages, sendMessage]`, where: - * - `messages` is an array of renderable messages (of type `React.ReactNode`) + * - `messages` is an array of messages, which should be provided as a prop + * to `MessageList` to be rendered * - `sendMessage` is a function which accepts a renderer callback, and * calls the callback to obtain a renderable message to append to * `messages`. The renderer callback should accept a `dismiss` function as @@ -16,14 +17,28 @@ export const MessageList = ({ messages }) => * when called. */ export const useMessages = () => { - const [messages, setMessages] = useState([]) + const [state, setState] = useState({ + messages: [], + nextId: 0, + }) const sendMessage = renderWithDismiss => { - const target = renderWithDismiss(() => { - setMessages(messages => messages.filter(message => message !== target)) - }) - setMessages(messages => [...messages, target]) + const id = state.nextId + const dismiss = () => { + setState(state => ({ + ...state, + messages: state.messages.filter(message => message.id !== id), + })) + } + const newMessage = { + render: () => renderWithDismiss(dismiss), + id: state.nextId, + } + setState(state => ({ + messages: [...state.messages, newMessage], + nextId: state.nextId + 1, + })) } - return [messages, sendMessage] + return [state.messages, sendMessage] } From 7f83aa0be8bda0324dccbbf3f4d9499209a79112 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Tue, 27 Aug 2019 09:26:47 -0700 Subject: [PATCH 29/45] PendingInvites: close/reset Create modal only if creation succeeds --- dev-portal/src/components/MessageList.jsx | 6 +- .../pages/Admin/Accounts/PendingInvites.jsx | 56 +++++++++++++++---- .../Accounts/__tests__/PendingInvites.jsx | 4 +- 3 files changed, 51 insertions(+), 15 deletions(-) diff --git a/dev-portal/src/components/MessageList.jsx b/dev-portal/src/components/MessageList.jsx index b5cb80dd5..39f76f95e 100644 --- a/dev-portal/src/components/MessageList.jsx +++ b/dev-portal/src/components/MessageList.jsx @@ -40,5 +40,9 @@ export const useMessages = () => { })) } - return [state.messages, sendMessage] + const clearMessages = () => { + setState(state => ({ ...state, messages: [] })) + } + + return [state.messages, sendMessage, clearMessages] } diff --git a/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx b/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx index 501035f06..a1b4a0c16 100644 --- a/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx +++ b/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx @@ -26,6 +26,11 @@ const PendingInvites = () => { false, ) const [messages, sendMessage] = MessageList.useMessages() + const [ + createModalMessages, + sendCreateModalMessage, + clearCreateModalMessages, + ] = MessageList.useMessages() const refreshAccounts = () => AccountService.fetchPendingInviteAccounts().then(accounts => @@ -45,26 +50,35 @@ const PendingInvites = () => { const onConfirmCreate = useCallback( async emailAddress => { setLoading(true) - closeCreateModal() + clearCreateModalMessages() try { await AccountService.createInviteByEmail(emailAddress) + closeCreateModal() + clearCreateModalMessages() sendMessage(dismiss => ( )) - await refreshAccounts() + // Don't need to wait for this + refreshAccounts().then(() => setLoading(false)) + return true } catch (error) { - sendMessage(dismiss => ( + sendCreateModalMessage(dismiss => ( )) - } finally { setLoading(false) + return false } }, - [sendMessage, closeCreateModal], + [ + sendMessage, + sendCreateModalMessage, + clearCreateModalMessages, + closeCreateModal, + ], ) const onConfirmDelete = useCallback(async () => { @@ -117,6 +131,7 @@ const PendingInvites = () => { onConfirm={onConfirmCreate} open={isCreateModalOpen} onClose={closeCreateModal} + messages={createModalMessages} /> { +/* + * Note: `onConfirm` should return a boolean indicating whether the creation + * succeeded. + */ +const CreateInviteModal = ({ onConfirm, open, onClose, messages }) => { const [email, setEmail] = useState('') + const [loading, setLoading] = useState(false) const isEmailValid = useMemo(() => /^[^@\s]+@[^@\s]+$/.test(email), [email]) const onChangeEmailAddress = useCallback( (_event, { value }) => setEmail(value), [], ) - const onClickCreate = useCallback(() => { - onConfirm(email) - setEmail('') + const onClickCreate = useCallback(async () => { + setLoading(true) + if (await onConfirm(email)) { + setEmail('') + } + setLoading(false) }, [onConfirm, email]) return ( @@ -162,19 +185,28 @@ const CreateInviteModal = ({ onConfirm, open, onClose }) => { Enter an email address below and select Create to send an invitation to create an account.

-
- - + diff --git a/dev-portal/src/pages/Admin/Accounts/__tests__/PendingInvites.jsx b/dev-portal/src/pages/Admin/Accounts/__tests__/PendingInvites.jsx index 503a8eff8..b1f7b918b 100644 --- a/dev-portal/src/pages/Admin/Accounts/__tests__/PendingInvites.jsx +++ b/dev-portal/src/pages/Admin/Accounts/__tests__/PendingInvites.jsx @@ -329,12 +329,12 @@ describe('PendingInvites page', () => { rtl.fireEvent.click(confirmCreateButton) await accountsTestUtils.waitForAccountsToLoad(page) - page.getAllByText((_content, element) => + rtl.getAllByText(createModal, (_content, element) => element.textContent.startsWith( 'Failed to send account invite to 000@example.com', ), ) - page.getAllByText((_content, element) => + rtl.getAllByText(createModal, (_content, element) => element.textContent.startsWith( 'Error message: You must construct additional pylons', ), From cbf846352f49af39fe1b8b9b0a1bde3e00027333 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Tue, 27 Aug 2019 09:30:10 -0700 Subject: [PATCH 30/45] PendingInvites: don't say creation failed if UI code fails --- .../src/pages/Admin/Accounts/PendingInvites.jsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx b/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx index a1b4a0c16..ab2d69f46 100644 --- a/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx +++ b/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx @@ -53,14 +53,6 @@ const PendingInvites = () => { clearCreateModalMessages() try { await AccountService.createInviteByEmail(emailAddress) - closeCreateModal() - clearCreateModalMessages() - sendMessage(dismiss => ( - - )) - // Don't need to wait for this - refreshAccounts().then(() => setLoading(false)) - return true } catch (error) { sendCreateModalMessage(dismiss => ( { setLoading(false) return false } + closeCreateModal() + clearCreateModalMessages() + sendMessage(dismiss => ( + + )) + // Don't need to wait for this + refreshAccounts().then(() => setLoading(false)) + return true }, [ sendMessage, From ae5f7faeecc06ae6c551ee33ce513cebffcb3ce2 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Tue, 27 Aug 2019 11:17:17 -0700 Subject: [PATCH 31/45] PendingInvites: remove unnecssary React.memo's --- .../pages/Admin/Accounts/PendingInvites.jsx | 114 +++++++++--------- 1 file changed, 55 insertions(+), 59 deletions(-) diff --git a/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx b/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx index ab2d69f46..a7f53550c 100644 --- a/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx +++ b/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx @@ -144,17 +144,20 @@ const PendingInvites = () => { } export default PendingInvites -const TableActions = React.memo( - ({ canCreate, onClickCreate, canDelete, onClickDelete }) => ( - - - - - - ), -) +const DeleteInviteModal = ({ account, onConfirm, open, onClose }) => + account ? ( + + Confirm invite deletion + +

+ Are you sure you want to delete this account invite for{' '} + {account.emailAddress}? This action is irreversible. +

+
+ + + + +
+ ) : null -const CreateSuccessMessage = React.memo(({ emailAddress, dismiss }) => ( +const CreateSuccessMessage = ({ emailAddress, dismiss }) => ( Sent account invite to {emailAddress}. -)) +) -const CreateFailureMessage = React.memo( - ({ emailAddress, errorMessage, dismiss }) => ( - - -

- Failed to send account invite to {emailAddress}. -

- {errorMessage &&

Error message: {errorMessage}

} -
-
- ), +const CreateFailureMessage = ({ emailAddress, errorMessage, dismiss }) => ( + + +

+ Failed to send account invite to {emailAddress}. +

+ {errorMessage &&

Error message: {errorMessage}

} +
+
) -const DeleteSuccessMessage = React.memo(({ account, dismiss }) => ( +const DeleteSuccessMessage = ({ account, dismiss }) => ( Deleted account invite for {account.emailAddress}. -)) +) -const DeleteFailureMessage = React.memo( - ({ account, errorMessage, dismiss }) => ( - - -

- Failed to delete account invite for{' '} - {account.emailAddress}. -

- {errorMessage &&

Error message: {errorMessage}

} -
-
- ), +const DeleteFailureMessage = ({ account, errorMessage, dismiss }) => ( + + +

+ Failed to delete account invite for{' '} + {account.emailAddress}. +

+ {errorMessage &&

Error message: {errorMessage}

} +
+
) From 7cee9bf0ca9d4680717e1c60b29826ad09646129 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Tue, 27 Aug 2019 10:55:34 -0700 Subject: [PATCH 32/45] Fix account field names (upcase; change *PoolId to *Id) --- .../Admin/Accounts/AccountsTableColumns.jsx | 70 ++++++------- .../pages/Admin/Accounts/PendingInvites.jsx | 10 +- .../pages/Admin/Accounts/PendingRequests.jsx | 18 ++-- .../Admin/Accounts/RegisteredAccounts.jsx | 20 ++-- .../Accounts/__tests__/AdminAccounts.jsx | 99 ++++++++++--------- .../Accounts/__tests__/PendingInvites.jsx | 86 ++++++++-------- .../Accounts/__tests__/PendingRequests.jsx | 59 ++++++----- .../Accounts/__tests__/RegisteredAccounts.jsx | 78 +++++++-------- dev-portal/src/services/accounts.js | 74 +++++++------- 9 files changed, 256 insertions(+), 258 deletions(-) diff --git a/dev-portal/src/components/Admin/Accounts/AccountsTableColumns.jsx b/dev-portal/src/components/Admin/Accounts/AccountsTableColumns.jsx index 6099e696e..87ed242a2 100644 --- a/dev-portal/src/components/Admin/Accounts/AccountsTableColumns.jsx +++ b/dev-portal/src/components/Admin/Accounts/AccountsTableColumns.jsx @@ -23,95 +23,87 @@ */ export const EmailAddress = { - id: 'emailAddress', + id: 'EmailAddress', title: 'Email address', - render: account => account.emailAddress, + render: account => account.EmailAddress, ordering: { - iteratee: 'emailAddress', + iteratee: 'EmailAddress', }, filtering: { - accessor: 'emailAddress', + accessor: 'EmailAddress', }, } export const DateRegistered = { - id: 'dateRegistered', + id: 'DateRegistered', title: 'Date registered', - render: account => formatDate(account.dateRegistered), + render: account => formatDate(account.DateRegistered), ordering: { - iteratee: 'dateRegistered', + iteratee: 'DateRegistered', }, } export const RegistrationMethod = { - id: 'registrationMethod', + id: 'RegistrationMethod', title: 'Registration method', - render: account => account.registrationMethod, + render: account => account.RegistrationMethod, } export const ApiKeyId = { - id: 'apiKeyId', + id: 'ApiKeyId', title: 'API key ID', - render: account => account.apiKeyId, + render: account => account.ApiKeyId, filtering: { - accessor: 'apiKeyId', + accessor: 'ApiKeyId', }, } export const Promoter = { - id: 'promoter', + id: 'Promoter', title: 'Promoter', - render: ({ promoterIdentityPoolId, promoterEmailAddress }) => - promoterIdentityPoolId - ? `${promoterEmailAddress} (${promoterIdentityPoolId})` - : '', + render: ({ PromoterIdentityId, PromoterEmailAddress }) => + PromoterIdentityId ? `${PromoterEmailAddress} (${PromoterIdentityId})` : '', filtering: { - accessor: ({ promoterIdentityPoolId, promoterEmailAddress }) => - promoterIdentityPoolId - ? `${promoterEmailAddress} ${promoterIdentityPoolId}` - : '', + accessor: ({ PromoterIdentityId, PromoterEmailAddress }) => + PromoterIdentityId ? `${PromoterEmailAddress} ${PromoterIdentityId}` : '', }, } export const Inviter = { - id: 'inviter', + id: 'Inviter', title: 'Inviter', - render: ({ inviterIdentityPoolId, inviterEmailAddress }) => - inviterIdentityPoolId - ? `${inviterEmailAddress} (${inviterIdentityPoolId})` - : '', + render: ({ InviterIdentityId, InviterEmailAddress }) => + InviterIdentityId ? `${InviterEmailAddress} (${InviterIdentityId})` : '', filtering: { - accessor: ({ inviterIdentityPoolId, inviterEmailAddress }) => - inviterIdentityPoolId - ? `${inviterEmailAddress} ${inviterIdentityPoolId}` - : '', + accessor: ({ InviterIdentityId, InviterEmailAddress }) => + InviterIdentityId ? `${InviterEmailAddress} ${InviterIdentityId}` : '', }, } export const DatePromoted = { - id: 'datePromoted', + id: 'DatePromoted', title: 'Date promoted', - render: ({ datePromoted }) => (datePromoted ? formatDate(datePromoted) : ''), + render: ({ DatePromoted }) => (DatePromoted ? formatDate(DatePromoted) : ''), ordering: { - iteratee: 'datePromoted', + iteratee: 'DatePromoted', }, } export const DateRequested = { - id: 'dateRequested', + id: 'DateRequested', title: 'Date requested', - render: account => formatDate(account.dateRequested), + render: account => formatDate(account.DateRequested), ordering: { - iteratee: 'dateRequested', + iteratee: 'DateRequested', }, } export const DateInvited = { - id: 'dateInvited', + id: 'DateInvited', title: 'Date invited', - render: account => formatDate(account.dateInvited), + render: account => formatDate(account.DateInvited), ordering: { - iteratee: 'dateInvited', + iteratee: 'DateInvited', }, } diff --git a/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx b/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx index a7f53550c..fe79a6a5a 100644 --- a/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx +++ b/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx @@ -85,9 +85,7 @@ const PendingInvites = () => { setLoading(true) closeDeleteModal() try { - await AccountService.deleteInviteByIdentityPoolId( - selectedAccount.identityPoolId, - ) + await AccountService.deleteInviteByIdentityId(selectedAccount.IdentityId) sendMessage(dismiss => ( )) @@ -224,7 +222,7 @@ const DeleteInviteModal = ({ account, onConfirm, open, onClose }) =>

Are you sure you want to delete this account invite for{' '} - {account.emailAddress}? This action is irreversible. + {account.EmailAddress}? This action is irreversible.

@@ -258,7 +256,7 @@ const CreateFailureMessage = ({ emailAddress, errorMessage, dismiss }) => ( const DeleteSuccessMessage = ({ account, dismiss }) => ( - Deleted account invite for {account.emailAddress}. + Deleted account invite for {account.EmailAddress}. ) @@ -268,7 +266,7 @@ const DeleteFailureMessage = ({ account, errorMessage, dismiss }) => (

Failed to delete account invite for{' '} - {account.emailAddress}. + {account.EmailAddress}.

{errorMessage &&

Error message: {errorMessage}

}
diff --git a/dev-portal/src/pages/Admin/Accounts/PendingRequests.jsx b/dev-portal/src/pages/Admin/Accounts/PendingRequests.jsx index daf0f42dc..ae6f82d0c 100644 --- a/dev-portal/src/pages/Admin/Accounts/PendingRequests.jsx +++ b/dev-portal/src/pages/Admin/Accounts/PendingRequests.jsx @@ -30,8 +30,8 @@ const PendingRequests = () => { const onConfirmApprove = useCallback(async () => { setLoading(true) try { - await AccountService.approveAccountRequestByIdentityPoolId( - selectedAccount.identityPoolId, + await AccountService.approveAccountRequestByIdentityId( + selectedAccount.IdentityId, ) sendMessage(dismiss => ( @@ -54,8 +54,8 @@ const PendingRequests = () => { setLoading(true) setDenyModalOpen(false) try { - await AccountService.denyAccountRequestByIdentityPoolId( - selectedAccount.identityPoolId, + await AccountService.denyAccountRequestByIdentityId( + selectedAccount.IdentityId, ) sendMessage(dismiss => ( @@ -127,7 +127,7 @@ const DenyAccountModal = React.memo(

Are you sure you want to deny this account request? The request will - be permanently deleted, and {account.emailAddress}{' '} + be permanently deleted, and {account.EmailAddress}{' '} will need to sign up again in order to request an account.

@@ -144,7 +144,7 @@ const DenyAccountModal = React.memo( const ApproveSuccessMessage = React.memo(({ account, dismiss }) => ( - Approved account request for {account.emailAddress}. + Approved account request for {account.EmailAddress}. )) @@ -155,7 +155,7 @@ const ApproveFailureMessage = React.memo(

Failed to approve account request for{' '} - {account.emailAddress}. + {account.EmailAddress}.

{errorMessage &&

Error message: {errorMessage}

}
@@ -166,7 +166,7 @@ const ApproveFailureMessage = React.memo( const DenySuccessMessage = React.memo(({ account, dismiss }) => ( - Denied account request for {account.emailAddress}. + Denied account request for {account.EmailAddress}. )) @@ -176,7 +176,7 @@ const DenyFailureMessage = React.memo(({ account, errorMessage, dismiss }) => (

Failed to deny account request for{' '} - {account.emailAddress}. + {account.EmailAddress}.

{errorMessage &&

Error message: {errorMessage}

}
diff --git a/dev-portal/src/pages/Admin/Accounts/RegisteredAccounts.jsx b/dev-portal/src/pages/Admin/Accounts/RegisteredAccounts.jsx index 7f4d290c5..c50ecf2ca 100644 --- a/dev-portal/src/pages/Admin/Accounts/RegisteredAccounts.jsx +++ b/dev-portal/src/pages/Admin/Accounts/RegisteredAccounts.jsx @@ -32,9 +32,7 @@ const RegisteredAccounts = () => { setLoading(true) setDeleteModalOpen(false) try { - await AccountService.deleteAccountByIdentityPoolId( - selectedAccount.identityPoolId, - ) + await AccountService.deleteAccountByIdentityId(selectedAccount.IdentityId) sendMessage(dismiss => ( )) @@ -56,8 +54,8 @@ const RegisteredAccounts = () => { setLoading(true) setPromoteModalOpen(false) try { - await AccountService.promoteAccountByIdentityPoolId( - selectedAccount.identityPoolId, + await AccountService.promoteAccountByIdentityId( + selectedAccount.IdentityId, ) sendMessage(dismiss => ( @@ -136,7 +134,7 @@ const DeleteAccountModal = React.memo(

Are you sure you want to delete the account{' '} - {account.emailAddress}, and de-activate the + {account.EmailAddress}, and de-activate the associated API key? This action is irreversible.

@@ -158,7 +156,7 @@ const PromoteAccountModal = React.memo(

Are you sure you want to promote the account{' '} - {account.emailAddress} to Admin? This will allow + {account.EmailAddress} to Admin? This will allow the account to perform any Admin actions, including deleting and promoting other accounts.

@@ -180,7 +178,7 @@ const PromoteAccountModal = React.memo( const DeleteSuccessMessage = React.memo(({ account, dismiss }) => ( - Deleted account {account.emailAddress}. + Deleted account {account.EmailAddress}. )) @@ -190,7 +188,7 @@ const DeleteFailureMessage = React.memo(

- Failed to delete account {account.emailAddress}. + Failed to delete account {account.EmailAddress}.

{errorMessage &&

Error message: {errorMessage}

}
@@ -201,7 +199,7 @@ const DeleteFailureMessage = React.memo( const PromoteSuccessMessage = React.memo(({ account, dismiss }) => ( - Promoted account {account.emailAddress}. + Promoted account {account.EmailAddress}. )) @@ -211,7 +209,7 @@ const PromoteFailureMessage = React.memo(

- Failed to promote account {account.emailAddress}. + Failed to promote account {account.EmailAddress}.

{errorMessage &&

Error message: {errorMessage}

}
diff --git a/dev-portal/src/pages/Admin/Accounts/__tests__/AdminAccounts.jsx b/dev-portal/src/pages/Admin/Accounts/__tests__/AdminAccounts.jsx index adb59b8a0..25a17770d 100644 --- a/dev-portal/src/pages/Admin/Accounts/__tests__/AdminAccounts.jsx +++ b/dev-portal/src/pages/Admin/Accounts/__tests__/AdminAccounts.jsx @@ -9,6 +9,7 @@ import * as accountsTestUtils from 'utils/accounts-test-utils' import AdminAccounts from 'pages/Admin/Accounts/AdminAccounts' import * as AccountsTable from 'components/Admin/Accounts/AccountsTable' import * as AccountService from 'services/accounts' +import * as AccountsTableColumns from 'components/Admin/Accounts/AccountsTableColumns' jest.mock('services/accounts') @@ -45,8 +46,8 @@ describe('AdminAccounts page', () => { await accountsTestUtils.waitForAccountsToLoad(page) _.take(MOCK_ADMINS, AccountsTable.DEFAULT_PAGE_SIZE).forEach( - ({ emailAddress }) => - accountsTestUtils.expectEmailIn(emailAddress, page.baseElement), + ({ EmailAddress }) => + accountsTestUtils.expectEmailIn(EmailAddress, page.baseElement), ) }) @@ -83,10 +84,10 @@ describe('AdminAccounts page', () => { // Check that first page is correct _(MOCK_ADMINS) - .orderBy(['emailAddress']) + .orderBy(['EmailAddress']) .take(AccountsTable.DEFAULT_PAGE_SIZE) - .forEach(({ emailAddress }) => - accountsTestUtils.expectEmailIn(emailAddress, table), + .forEach(({ EmailAddress }) => + accountsTestUtils.expectEmailIn(EmailAddress, table), ) // Check that last page is correct @@ -94,13 +95,13 @@ describe('AdminAccounts page', () => { const lastPageButton = rtl.getByLabelText(pagination, 'Last item') rtl.fireEvent.click(lastPageButton) _(MOCK_ADMINS) - .orderBy(['emailAddress']) + .orderBy(['EmailAddress']) .drop( Math.floor(MOCK_ADMINS.length / AccountsTable.DEFAULT_PAGE_SIZE) * AccountsTable.DEFAULT_PAGE_SIZE, ) - .forEach(({ emailAddress }) => - accountsTestUtils.expectEmailIn(emailAddress, table), + .forEach(({ EmailAddress }) => + accountsTestUtils.expectEmailIn(EmailAddress, table), ) // Order descending, go back to first page @@ -110,10 +111,10 @@ describe('AdminAccounts page', () => { // Check that first page is correct _(MOCK_ADMINS) - .orderBy(['emailAddress'], ['desc']) + .orderBy(['EmailAddress'], ['desc']) .take(AccountsTable.DEFAULT_PAGE_SIZE) - .forEach(({ emailAddress }) => - accountsTestUtils.expectEmailIn(emailAddress, table), + .forEach(({ EmailAddress }) => + accountsTestUtils.expectEmailIn(EmailAddress, table), ) }) @@ -132,10 +133,10 @@ describe('AdminAccounts page', () => { // Check that first page is correct _(MOCK_ADMINS) - .orderBy(['datePromoted'], ['asc']) + .orderBy(['DatePromoted'], ['asc']) .take(AccountsTable.DEFAULT_PAGE_SIZE) - .forEach(({ emailAddress }) => - accountsTestUtils.expectEmailIn(emailAddress, table), + .forEach(({ EmailAddress }) => + accountsTestUtils.expectEmailIn(EmailAddress, table), ) }) @@ -151,18 +152,20 @@ describe('AdminAccounts page', () => { rtl.fireEvent.change(filterInput, { target: { value: '1' } }) _(MOCK_ADMINS) - .filter(({ emailAddress }) => emailAddress.includes('1')) - .forEach(({ emailAddress }) => - accountsTestUtils.expectEmailIn(emailAddress, table), + .filter(({ EmailAddress }) => EmailAddress.includes('1')) + .forEach(({ EmailAddress }) => + accountsTestUtils.expectEmailIn(EmailAddress, table), ) rtl.fireEvent.change(filterInput, { target: { value: '9' } }) expect( - accountsTestUtils.queryAllByColumnText( - table, - 'emailAddress', - /@example\.com/, - ).map(el => el.textContent), + accountsTestUtils + .queryAllByColumnText( + table, + AccountsTableColumns.EmailAddress.id, + /@example\.com/, + ) + .map(el => el.textContent), ).toEqual(['90@example.com']) }) @@ -186,13 +189,13 @@ describe('AdminAccounts page', () => { rtl.fireEvent.change(filterInput, { target: { value: '20@example.com' } }) _(MOCK_ADMINS) .filter({ promoterEmailAddress: '20@example.com' }) - .forEach(({ emailAddress }) => - accountsTestUtils.expectEmailIn(emailAddress, table), + .forEach(({ EmailAddress }) => + accountsTestUtils.expectEmailIn(EmailAddress, table), ) expect( accountsTestUtils.queryAllByColumnText( table, - 'emailAddress', + AccountsTableColumns.EmailAddress.id, /@example\.com/, ), ).toHaveLength(3) @@ -201,7 +204,7 @@ describe('AdminAccounts page', () => { expect( accountsTestUtils.queryAllByColumnText( table, - 'emailAddress', + AccountsTableColumns.EmailAddress.id, /@example\.com/, ), ).toHaveLength(0) @@ -224,29 +227,31 @@ describe('AdminAccounts page', () => { const filterByApiKeyIdOption = rtl.getByText(filterDropdown, 'Promoter') rtl.fireEvent.click(filterByApiKeyIdOption) - rtl.fireEvent.change(filterInput, { target: { value: 'identityPoolId20' } }) + rtl.fireEvent.change(filterInput, { target: { value: 'identityId20' } }) const expectedEmails = _(MOCK_ADMINS) - .filter(({ promoterIdentityPoolId }) => - (promoterIdentityPoolId || '').includes('identityPoolId20'), + .filter(({ PromoterIdentityId }) => + (PromoterIdentityId || '').includes('identityId20'), ) - .map(({ emailAddress }) => emailAddress) + .map(({ EmailAddress }) => EmailAddress) .sortBy() .value() expect( _.sortBy( - accountsTestUtils.queryAllByColumnText( - table, - 'emailAddress', - /@example\.com/, - ).map(el => el.textContent), + accountsTestUtils + .queryAllByColumnText( + table, + AccountsTableColumns.EmailAddress.id, + /@example\.com/, + ) + .map(el => el.textContent), ), ).toEqual(expectedEmails) - rtl.fireEvent.change(filterInput, { target: { value: 'identityPoolId30' } }) + rtl.fireEvent.change(filterInput, { target: { value: 'identityId30' } }) expect( accountsTestUtils.queryAllByColumnText( table, - 'emailAddress', + AccountsTableColumns.EmailAddress.id, /@example\.com/, ), ).toHaveLength(0) @@ -277,19 +282,19 @@ const MOCK_DATES_PROMOTED = (() => { const MOCK_ADMIN_STEP = 10 -const MOCK_ACCOUNTS = (() => { - return Array.from({ length: NUM_MOCK_ACCOUNTS }).map((_value, index) => { +const MOCK_ACCOUNTS = _.range(0, NUM_MOCK_ACCOUNTS, MOCK_ADMIN_STEP).map( + index => { const promoter = MOCK_PROMOTERS[index] return { - identityPoolId: `identityPoolId${index}`, - userPoolId: `userPoolId${index}`, - emailAddress: `${index}@example.com`, - datePromoted: MOCK_DATES_PROMOTED[index], - promoterEmailAddress: promoter && `${promoter}@example.com`, - promoterIdentityPoolId: promoter && `identityPoolId${promoter}`, + IdentityId: `identityId${index}`, + UserId: `userId${index}`, + EmailAddress: `${index}@example.com`, + DatePromoted: MOCK_DATES_PROMOTED[index], + PromoterEmailAddress: promoter && `${promoter}@example.com`, + PromoterIdentityId: promoter && `identityId${promoter}`, isAdmin: index % MOCK_ADMIN_STEP === 0, } - }) -})() + }, +) const MOCK_ADMINS = MOCK_ACCOUNTS.filter(account => account.isAdmin) diff --git a/dev-portal/src/pages/Admin/Accounts/__tests__/PendingInvites.jsx b/dev-portal/src/pages/Admin/Accounts/__tests__/PendingInvites.jsx index b1f7b918b..a98d9cf9b 100644 --- a/dev-portal/src/pages/Admin/Accounts/__tests__/PendingInvites.jsx +++ b/dev-portal/src/pages/Admin/Accounts/__tests__/PendingInvites.jsx @@ -9,6 +9,7 @@ import * as accountsTestUtils from 'utils/accounts-test-utils' import PendingInvites from 'pages/Admin/Accounts/PendingInvites' import * as AccountsTable from 'components/Admin/Accounts/AccountsTable' import * as AccountService from 'services/accounts' +import * as AccountsTableColumns from 'components/Admin/Accounts/AccountsTableColumns' jest.mock('services/accounts') @@ -45,8 +46,8 @@ describe('PendingInvites page', () => { await accountsTestUtils.waitForAccountsToLoad(page) _.take(MOCK_ACCOUNTS, AccountsTable.DEFAULT_PAGE_SIZE).forEach( - ({ emailAddress }) => - accountsTestUtils.expectEmailIn(emailAddress, page.baseElement), + ({ EmailAddress }) => + accountsTestUtils.expectEmailIn(EmailAddress, page.baseElement), ) }) @@ -83,10 +84,10 @@ describe('PendingInvites page', () => { // Check that first page is correct _(MOCK_ACCOUNTS) - .orderBy(['emailAddress']) + .orderBy(['EmailAddress']) .take(AccountsTable.DEFAULT_PAGE_SIZE) - .forEach(({ emailAddress }) => - accountsTestUtils.expectEmailIn(emailAddress, table), + .forEach(({ EmailAddress }) => + accountsTestUtils.expectEmailIn(EmailAddress, table), ) // Check that last page is correct @@ -94,13 +95,13 @@ describe('PendingInvites page', () => { const lastPageButton = rtl.getByLabelText(pagination, 'Last item') rtl.fireEvent.click(lastPageButton) _(MOCK_ACCOUNTS) - .orderBy(['emailAddress']) + .orderBy(['EmailAddress']) .drop( Math.floor(MOCK_ACCOUNTS.length / AccountsTable.DEFAULT_PAGE_SIZE) * AccountsTable.DEFAULT_PAGE_SIZE, ) - .forEach(({ emailAddress }) => - accountsTestUtils.expectEmailIn(emailAddress, table), + .forEach(({ EmailAddress }) => + accountsTestUtils.expectEmailIn(EmailAddress, table), ) // Order descending, go back to first page @@ -110,10 +111,10 @@ describe('PendingInvites page', () => { // Check that first page is correct _(MOCK_ACCOUNTS) - .orderBy(['emailAddress'], ['desc']) + .orderBy(['EmailAddress'], ['desc']) .take(AccountsTable.DEFAULT_PAGE_SIZE) - .forEach(({ emailAddress }) => - accountsTestUtils.expectEmailIn(emailAddress, table), + .forEach(({ EmailAddress }) => + accountsTestUtils.expectEmailIn(EmailAddress, table), ) }) @@ -132,10 +133,10 @@ describe('PendingInvites page', () => { // Check that first page is correct _(MOCK_ACCOUNTS) - .orderBy(['dateInvited'], ['asc']) + .orderBy(['DateInvited'], ['asc']) .take(AccountsTable.DEFAULT_PAGE_SIZE) - .forEach(({ emailAddress }) => - accountsTestUtils.expectEmailIn(emailAddress, table), + .forEach(({ EmailAddress }) => + accountsTestUtils.expectEmailIn(EmailAddress, table), ) }) @@ -151,16 +152,20 @@ describe('PendingInvites page', () => { rtl.fireEvent.change(filterInput, { target: { value: '1' } }) _(MOCK_ACCOUNTS) - .filter(({ emailAddress }) => emailAddress.includes('1')) + .filter(({ EmailAddress }) => EmailAddress.includes('1')) .take(AccountsTable.DEFAULT_PAGE_SIZE) - .forEach(({ emailAddress }) => - accountsTestUtils.expectEmailIn(emailAddress, table), + .forEach(({ EmailAddress }) => + accountsTestUtils.expectEmailIn(EmailAddress, table), ) rtl.fireEvent.change(filterInput, { target: { value: '90' } }) expect( accountsTestUtils - .queryAllByColumnText(table, 'emailAddress', /@example\.com/) + .queryAllByColumnText( + table, + AccountsTableColumns.EmailAddress.id, + /@example\.com/, + ) .map(el => el.textContent), ).toEqual(['90@example.com']) }) @@ -186,8 +191,8 @@ describe('PendingInvites page', () => { _(MOCK_ACCOUNTS) .filter({ inviterEmailAddress: '20@example.com' }) .take(AccountsTable.DEFAULT_PAGE_SIZE) - .forEach(({ emailAddress }) => - accountsTestUtils.expectEmailIn(emailAddress, table), + .forEach(({ EmailAddress }) => + accountsTestUtils.expectEmailIn(EmailAddress, table), ) rtl.fireEvent.change(filterInput, { target: { value: '30@example.com' } }) @@ -211,12 +216,12 @@ describe('PendingInvites page', () => { .fn() .mockImplementation(emailAddress => { createdAccounts.push({ - identityPoolId: `createedIdentityId${createdAccounts.length}`, - userPoolId: `createdUserId${createdAccounts.length}`, - emailAddress: emailAddress, - dateInvited: new Date(), - inviterEmailAddress: 'you@example.com', - inviterIdentityPoolId: 'me', + IdentityId: `createedIdentityId${createdAccounts.length}`, + UserId: `createdUserId${createdAccounts.length}`, + EmailAddress: emailAddress, + DateInvited: new Date(), + InviterEmailAddress: 'you@example.com', + InviterIdentityId: 'me', }) }) @@ -348,15 +353,14 @@ describe('PendingInvites page', () => { .mockImplementation(() => Promise.resolve( MOCK_ACCOUNTS.filter( - ({ identityPoolId }) => - !deletedIdentityIds.includes(identityPoolId), + ({ IdentityId }) => !deletedIdentityIds.includes(IdentityId), ), ), ) - AccountService.deleteInviteByIdentityPoolId = jest + AccountService.deleteInviteByIdentityId = jest .fn() - .mockImplementation(identityPoolId => { - deletedIdentityIds.push(identityPoolId) + .mockImplementation(identityId => { + deletedIdentityIds.push(identityId) }) const page = renderPage() @@ -367,7 +371,7 @@ describe('PendingInvites page', () => { const targetAccountEmailCell = accountsTestUtils.queryByColumnText( page.baseElement, - 'emailAddress', + AccountsTableColumns.EmailAddress.id, '5@example.com', ) expect(targetAccountEmailCell).toBeInTheDocument() @@ -404,7 +408,7 @@ describe('PendingInvites page', () => { expect( accountsTestUtils.queryByColumnText( table, - 'emailAddress', + AccountsTableColumns.EmailAddress.id, '5@example.com', ), ).toBeNull() @@ -414,7 +418,7 @@ describe('PendingInvites page', () => { AccountService.fetchPendingInviteAccounts = jest .fn() .mockResolvedValue(MOCK_ACCOUNTS) - AccountService.deleteInviteByIdentityPoolId = jest + AccountService.deleteInviteByIdentityId = jest .fn() .mockRejectedValue(new Error('Target lost.')) @@ -423,7 +427,7 @@ describe('PendingInvites page', () => { const targetAccountEmailCell = accountsTestUtils.queryByColumnText( page.baseElement, - 'emailAddress', + AccountsTableColumns.EmailAddress.id, '2@example.com', ) expect(targetAccountEmailCell).toBeInTheDocument() @@ -473,10 +477,10 @@ const MOCK_DATES_INVITED = (() => { })() const MOCK_ACCOUNTS = MOCK_INVITERS.map((inviter, index) => ({ - identityPoolId: `identityPoolId${index}`, - userPoolId: `userPoolId${index}`, - emailAddress: `${index}@example.com`, - dateInvited: MOCK_DATES_INVITED[index], - inviterEmailAddress: inviter && `${inviter}@example.com`, - inviterIdentityPoolId: inviter && `identityPoolId${inviter}`, + IdentityId: `identityId${index}`, + UserId: `userId${index}`, + EmailAddress: `${index}@example.com`, + DateInvited: MOCK_DATES_INVITED[index], + InviterEmailAddress: inviter && `${inviter}@example.com`, + InviterIdentityId: inviter && `identityId${inviter}`, })) diff --git a/dev-portal/src/pages/Admin/Accounts/__tests__/PendingRequests.jsx b/dev-portal/src/pages/Admin/Accounts/__tests__/PendingRequests.jsx index 1080b744d..dc2d33efa 100644 --- a/dev-portal/src/pages/Admin/Accounts/__tests__/PendingRequests.jsx +++ b/dev-portal/src/pages/Admin/Accounts/__tests__/PendingRequests.jsx @@ -9,6 +9,7 @@ import * as accountsTestUtils from 'utils/accounts-test-utils' import PendingRequests from 'pages/Admin/Accounts/PendingRequests' import * as AccountsTable from 'components/Admin/Accounts/AccountsTable' import * as AccountService from 'services/accounts' +import * as AccountsTableColumns from 'components/Admin/Accounts/AccountsTableColumns' jest.mock('services/accounts') @@ -45,8 +46,8 @@ describe('PendingRequests page', () => { await accountsTestUtils.waitForAccountsToLoad(page) _.take(MOCK_ACCOUNTS, AccountsTable.DEFAULT_PAGE_SIZE).forEach( - ({ emailAddress }) => - accountsTestUtils.expectEmailIn(emailAddress, page.baseElement), + ({ EmailAddress }) => + accountsTestUtils.expectEmailIn(EmailAddress, page.baseElement), ) }) @@ -83,10 +84,10 @@ describe('PendingRequests page', () => { // Check that first page is correct _(MOCK_ACCOUNTS) - .orderBy(['emailAddress']) + .orderBy(['EmailAddress']) .take(AccountsTable.DEFAULT_PAGE_SIZE) - .forEach(({ emailAddress }) => - accountsTestUtils.expectEmailIn(emailAddress, table), + .forEach(({ EmailAddress }) => + accountsTestUtils.expectEmailIn(EmailAddress, table), ) // Check that last page is correct @@ -94,13 +95,13 @@ describe('PendingRequests page', () => { const lastPageButton = rtl.getByLabelText(pagination, 'Last item') rtl.fireEvent.click(lastPageButton) _(MOCK_ACCOUNTS) - .orderBy(['emailAddress']) + .orderBy(['EmailAddress']) .drop( Math.floor(MOCK_ACCOUNTS.length / AccountsTable.DEFAULT_PAGE_SIZE) * AccountsTable.DEFAULT_PAGE_SIZE, ) - .forEach(({ emailAddress }) => - accountsTestUtils.expectEmailIn(emailAddress, table), + .forEach(({ EmailAddress }) => + accountsTestUtils.expectEmailIn(EmailAddress, table), ) // Order descending, go back to first page @@ -110,10 +111,10 @@ describe('PendingRequests page', () => { // Check that first page is correct _(MOCK_ACCOUNTS) - .orderBy(['emailAddress'], ['desc']) + .orderBy(['EmailAddress'], ['desc']) .take(AccountsTable.DEFAULT_PAGE_SIZE) - .forEach(({ emailAddress }) => - accountsTestUtils.expectEmailIn(emailAddress, table), + .forEach(({ EmailAddress }) => + accountsTestUtils.expectEmailIn(EmailAddress, table), ) }) @@ -132,10 +133,10 @@ describe('PendingRequests page', () => { // Check that first page is correct _(MOCK_ACCOUNTS) - .orderBy(['dateRequested'], ['asc']) + .orderBy(['DateRequested'], ['asc']) .take(AccountsTable.DEFAULT_PAGE_SIZE) - .forEach(({ emailAddress }) => - accountsTestUtils.expectEmailIn(emailAddress, table), + .forEach(({ EmailAddress }) => + accountsTestUtils.expectEmailIn(EmailAddress, table), ) }) @@ -151,16 +152,20 @@ describe('PendingRequests page', () => { rtl.fireEvent.change(filterInput, { target: { value: '1' } }) _(MOCK_ACCOUNTS) - .filter(({ emailAddress }) => emailAddress.includes('1')) + .filter(({ EmailAddress }) => EmailAddress.includes('1')) .take(AccountsTable.DEFAULT_PAGE_SIZE) - .forEach(({ emailAddress }) => - accountsTestUtils.expectEmailIn(emailAddress, table), + .forEach(({ EmailAddress }) => + accountsTestUtils.expectEmailIn(EmailAddress, table), ) rtl.fireEvent.change(filterInput, { target: { value: '90' } }) expect( accountsTestUtils - .queryAllByColumnText(table, 'emailAddress', /@example\.com/) + .queryAllByColumnText( + table, + AccountsTableColumns.EmailAddress.id, + /@example\.com/, + ) .map(el => el.textContent), ).toEqual(['90@example.com']) }) @@ -174,12 +179,12 @@ describe('PendingRequests page', () => { MOCK_ACCOUNTS.filter( account => !deletedEmails.some( - deletedEmail => account.emailAddress === deletedEmail, + deletedEmail => account.EmailAddress === deletedEmail, ), ), ), ) - AccountService.denyAccountRequestByIdentityPoolId = jest + AccountService.denyAccountRequestByIdentityId = jest .fn() .mockResolvedValueOnce(undefined) .mockResolvedValueOnce(undefined) @@ -208,7 +213,7 @@ describe('PendingRequests page', () => { expect( accountsTestUtils.queryByColumnText( table, - 'emailAddress', + AccountsTableColumns.EmailAddress.id, '3@example.com', ), ).toBeNull() @@ -230,14 +235,14 @@ describe('PendingRequests page', () => { expect( accountsTestUtils.queryByColumnText( table, - 'emailAddress', + AccountsTableColumns.EmailAddress.id, '3@example.com', ), ).toBeNull() expect( accountsTestUtils.queryByColumnText( table, - 'emailAddress', + AccountsTableColumns.EmailAddress.id, '4@example.com', ), ).toBeNull() @@ -256,10 +261,10 @@ const MOCK_DATES_REQUESTED = (() => { const MOCK_ACCOUNTS = (() => { return Array.from({ length: NUM_MOCK_ACCOUNTS }).map((_value, index) => { return { - identityPoolId: `identityPoolId${index}`, - userPoolId: `userPoolId${index}`, - emailAddress: `${index}@example.com`, - dateRequested: MOCK_DATES_REQUESTED[index], + IdentityId: `identityId${index}`, + UserId: `userId${index}`, + EmailAddress: `${index}@example.com`, + DateRequested: MOCK_DATES_REQUESTED[index], } }) })() diff --git a/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx b/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx index 5f8d1d7fb..ec406f34b 100644 --- a/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx +++ b/dev-portal/src/pages/Admin/Accounts/__tests__/RegisteredAccounts.jsx @@ -9,6 +9,7 @@ import * as accountsTestUtils from 'utils/accounts-test-utils' import RegisteredAccounts from 'pages/Admin/Accounts/RegisteredAccounts' import * as AccountsTable from 'components/Admin/Accounts/AccountsTable' import * as AccountService from 'services/accounts' +import * as AccountsTableColumns from 'components/Admin/Accounts/AccountsTableColumns' jest.mock('services/accounts') @@ -82,21 +83,21 @@ describe('RegisteredAccounts page', () => { // Check that first page is correct _(MOCK_ACCOUNTS) - .orderBy(['emailAddress'], ['asc']) + .orderBy(['EmailAddress'], ['asc']) .take(AccountsTable.DEFAULT_PAGE_SIZE) - .forEach(({ emailAddress }) => rtl.getByText(table, emailAddress)) + .forEach(({ EmailAddress }) => rtl.getByText(table, EmailAddress)) // Check that last page is correct const pagination = page.getByRole('navigation') const lastPageButton = rtl.getByLabelText(pagination, 'Last item') rtl.fireEvent.click(lastPageButton) _(MOCK_ACCOUNTS) - .orderBy(['emailAddress'], ['asc']) + .orderBy(['EmailAddress'], ['asc']) .drop( Math.floor(NUM_MOCK_ACCOUNTS / AccountsTable.DEFAULT_PAGE_SIZE) * AccountsTable.DEFAULT_PAGE_SIZE, ) - .forEach(({ emailAddress }) => rtl.getByText(table, emailAddress)) + .forEach(({ EmailAddress }) => rtl.getByText(table, EmailAddress)) // Order descending, go back to first page rtl.fireEvent.click(emailAddressHeader) @@ -105,9 +106,9 @@ describe('RegisteredAccounts page', () => { // Check that first page is correct _(MOCK_ACCOUNTS) - .orderBy(['emailAddress'], ['desc']) + .orderBy(['EmailAddress'], ['desc']) .take(AccountsTable.DEFAULT_PAGE_SIZE) - .forEach(({ emailAddress }) => rtl.getByText(table, emailAddress)) + .forEach(({ EmailAddress }) => rtl.getByText(table, EmailAddress)) }) it('orders accounts by date registered', async () => { @@ -125,9 +126,9 @@ describe('RegisteredAccounts page', () => { // Check that first page is correct _(MOCK_ACCOUNTS) - .orderBy('dateRegistered') + .orderBy('DateRegistered') .take(AccountsTable.DEFAULT_PAGE_SIZE) - .forEach(({ emailAddress }) => rtl.getByText(table, emailAddress)) + .forEach(({ EmailAddress }) => rtl.getByText(table, EmailAddress)) }) it('filters accounts by email address', async () => { @@ -142,9 +143,9 @@ describe('RegisteredAccounts page', () => { rtl.fireEvent.change(filterInput, { target: { value: '11' } }) _(MOCK_ACCOUNTS) - .filter(({ emailAddress }) => emailAddress.includes('11')) + .filter(({ EmailAddress }) => EmailAddress.includes('11')) .take(AccountsTable.DEFAULT_PAGE_SIZE) - .forEach(({ emailAddress }) => rtl.getByText(table, emailAddress)) + .forEach(({ EmailAddress }) => rtl.getByText(table, EmailAddress)) rtl.fireEvent.change(filterInput, { target: { value: '111' } }) rtl.getByText(table, '111@example.com') @@ -171,9 +172,9 @@ describe('RegisteredAccounts page', () => { rtl.fireEvent.change(filterInput, { target: { value: '15' } }) _(MOCK_ACCOUNTS) - .filter(({ apiKeyId }) => apiKeyId.includes('15')) + .filter(({ ApiKeyId }) => ApiKeyId.includes('15')) .take(AccountsTable.DEFAULT_PAGE_SIZE) - .forEach(({ apiKeyId }) => rtl.getByText(table, apiKeyId)) + .forEach(({ ApiKeyId }) => rtl.getByText(table, ApiKeyId)) rtl.fireEvent.change(filterInput, { target: { value: '155' } }) rtl.getByText(table, 'apiKeyId155') @@ -197,25 +198,25 @@ describe('RegisteredAccounts page', () => { rtl.fireEvent.change(filterInput, { target: { value: '13' } }) rtl.fireEvent.click(dateRegisteredHeader) _(MOCK_ACCOUNTS) - .filter(({ emailAddress }) => emailAddress.includes('13')) - .orderBy('dateRegistered') + .filter(({ EmailAddress }) => EmailAddress.includes('13')) + .orderBy('DateRegistered') .take(AccountsTable.DEFAULT_PAGE_SIZE) - .forEach(({ emailAddress }) => rtl.getByText(table, emailAddress)) + .forEach(({ EmailAddress }) => rtl.getByText(table, EmailAddress)) }) it('deletes an account', async () => { const targetAccountEmail = '1@example.com' - const targetAccountIdentityPoolId = 'identityPoolId1' + const targetAccountIdentityId = 'identityId1' AccountService.fetchRegisteredAccounts = jest .fn() .mockResolvedValueOnce(MOCK_ACCOUNTS) .mockResolvedValueOnce( MOCK_ACCOUNTS.filter( - account => account.emailAddress !== targetAccountEmail, + account => account.EmailAddress !== targetAccountEmail, ), ) - AccountService.deleteAccountByIdentityPoolId = jest + AccountService.deleteAccountByIdentityId = jest .fn() .mockResolvedValueOnce(undefined) @@ -237,12 +238,10 @@ describe('RegisteredAccounts page', () => { await accountsTestUtils.waitForAccountsToLoad(page) expect(rtl.queryByText(document, 'Confirm deletion')).toBeNull() - expect( - AccountService.deleteAccountByIdentityPoolId.mock.calls, - ).toHaveLength(1) - expect( - AccountService.deleteAccountByIdentityPoolId.mock.calls[0][0], - ).toEqual(targetAccountIdentityPoolId) + expect(AccountService.deleteAccountByIdentityId.mock.calls).toHaveLength(1) + expect(AccountService.deleteAccountByIdentityId.mock.calls[0][0]).toEqual( + targetAccountIdentityId, + ) await rtl.wait(() => expect(page.getByText(/Deleted account/)).toBeInTheDocument(), @@ -257,7 +256,7 @@ describe('RegisteredAccounts page', () => { AccountService.fetchRegisteredAccounts = jest .fn() .mockResolvedValueOnce(MOCK_ACCOUNTS) - AccountService.deleteAccountByIdentityPoolId = jest + AccountService.deleteAccountByIdentityId = jest .fn() .mockImplementation(() => Promise.reject(new Error(errorMessage))) @@ -283,12 +282,12 @@ describe('RegisteredAccounts page', () => { it('promotes an account', async () => { const targetAccountEmail = '2@example.com' - const targetAccountIdentityPoolId = 'identityPoolId2' + const targetAccountIdentityId = 'identityId2' AccountService.fetchRegisteredAccounts = jest .fn() .mockResolvedValueOnce(MOCK_ACCOUNTS) - AccountService.promoteAccountByIdentityPoolId = jest + AccountService.promoteAccountByIdentityId = jest .fn() .mockResolvedValueOnce(undefined) @@ -310,12 +309,10 @@ describe('RegisteredAccounts page', () => { await accountsTestUtils.waitForAccountsToLoad(page) expect(rtl.queryByText(document, 'Confirm promotion')).toBeNull() - expect( - AccountService.promoteAccountByIdentityPoolId.mock.calls, - ).toHaveLength(1) - expect( - AccountService.promoteAccountByIdentityPoolId.mock.calls[0][0], - ).toEqual(targetAccountIdentityPoolId) + expect(AccountService.promoteAccountByIdentityId.mock.calls).toHaveLength(1) + expect(AccountService.promoteAccountByIdentityId.mock.calls[0][0]).toEqual( + targetAccountIdentityId, + ) await rtl.wait(() => expect(page.getByText(/Promoted account/)).toBeInTheDocument(), @@ -330,7 +327,7 @@ describe('RegisteredAccounts page', () => { AccountService.fetchRegisteredAccounts = jest .fn() .mockResolvedValueOnce(MOCK_ACCOUNTS) - AccountService.deleteAccountByIdentityPoolId = jest + AccountService.deleteAccountByIdentityId = jest .fn() .mockImplementation(() => Promise.reject(new Error(errorMessage))) @@ -365,11 +362,10 @@ const MOCK_DATES_REGISTERED = (() => { })() const MOCK_ACCOUNTS = _.range(NUM_MOCK_ACCOUNTS).map(index => ({ - identityPoolId: `identityPoolId${index}`, - userPoolId: `userPoolId${index}`, - emailAddress: `${index}@example.com`, - dateRegistered: MOCK_DATES_REGISTERED[index].toJSON(), - apiKeyId: `apiKeyId${index}`, - registrationMethod: _.sample(['open', 'invite', 'request']), - isAdmin: index % 20 === 0, + IdentityId: `identityId${index}`, + UserId: `userId${index}`, + EmailAddress: `${index}@example.com`, + DateRegistered: MOCK_DATES_REGISTERED[index].toJSON(), + ApiKeyId: `apiKeyId${index}`, + RegistrationMethod: _.sample(['open', 'invite', 'request']), })) diff --git a/dev-portal/src/services/accounts.js b/dev-portal/src/services/accounts.js index 38d701499..5811a24ba 100644 --- a/dev-portal/src/services/accounts.js +++ b/dev-portal/src/services/accounts.js @@ -6,7 +6,7 @@ const NUM_MOCK_ACCOUNTS = 157 // should be prime const mockData = (() => { const now = Date.now() const adminStep = 10 - return Array.from({ length: NUM_MOCK_ACCOUNTS }).map((_value, index) => { + return _.range(NUM_MOCK_ACCOUNTS).map(index => { let inviter = 1 let promoter = null if (_.inRange(index, 20, 90)) { @@ -21,21 +21,21 @@ const mockData = (() => { } return { - identityPoolId: `identityPoolId${index}`, - userPoolId: `userPoolId${index}`, - emailAddress: `${index}@example.com`, - datePromoted: + IdentityId: `identityId${index}`, + UserId: `userId${index}`, + EmailAddress: `${index}@example.com`, + DatePromoted: promoter && new Date(now + ((index * 3) % NUM_MOCK_ACCOUNTS) * 1000).toJSON(), - promoterEmailAddress: promoter && `${promoter}@example.com`, - promoterIdentityPoolId: promoter && `identityPoolId${promoter}`, - inviterEmailAddress: inviter && `${inviter}@example.com`, - inviterIdentityPoolId: inviter && `identityPoolId${inviter}`, - dateRegistered: new Date( + PromoterEmailAddress: promoter && `${promoter}@example.com`, + PromoterIdentityId: promoter && `identityId${promoter}`, + InviterEmailAddress: inviter && `${inviter}@example.com`, + InviterIdentityId: inviter && `identityId${inviter}`, + DateRegistered: new Date( now + ((index * 3) % NUM_MOCK_ACCOUNTS) * 1000, ).toJSON(), - apiKeyId: `apiKeyId${index}`, - registrationMethod: _.sample(['open', 'invite', 'request']), + ApiKeyId: `apiKeyId${index}`, + RegistrationMethod: _.sample(['open', 'invite', 'request']), isAdmin: index % adminStep === 0, } }) @@ -68,31 +68,31 @@ export const fetchPendingInviteAccounts = () => { return resolveAfter(1500, mockPendingInviteAccounts.slice()) } -export const deleteAccountByIdentityPoolId = async identityPoolId => { +export const deleteAccountByIdentityId = async identityId => { await resolveAfter(1500) const accountIndex = mockData.findIndex( - account => account.identityPoolId === identityPoolId, + account => account.IdentityId === identityId, ) if (accountIndex === -1) { throw new Error('Account not found!') } - if (identityPoolId.endsWith('10')) { + if (identityId.endsWith('10')) { throw new Error('Something weird happened!') } mockData.splice(accountIndex, 1) } -export const deleteInviteByIdentityPoolId = async identityPoolId => { +export const deleteInviteByIdentityId = async identityId => { await resolveAfter(1500) const accountIndex = mockPendingInviteAccounts.findIndex( - account => account.identityPoolId === identityPoolId, + account => account.IdentityId === identityId, ) if (accountIndex === -1) { throw new Error('Account not found!') } - if (identityPoolId.endsWith('10')) { + if (identityId.endsWith('10')) { throw new Error('Something weird happened!') } mockPendingInviteAccounts.splice(accountIndex, 1) @@ -102,24 +102,24 @@ export const createInviteByEmail = async emailAddress => { await resolveAfter(1500) const account = { - identityPoolId: `temp`, - userPoolId: `temp`, - emailAddress, - dateInvited: new Date(Date.now()).toJSON(), - inviterEmailAddress: `you@localhost`, - inviterIdentityPoolId: `yourIdentityId`, - apiKeyId: `temp`, - registrationMethod: `invite`, + IdentityId: `temp`, + UserId: `temp`, + EmailAddress: emailAddress, + DateInvited: new Date(Date.now()).toJSON(), + InviterEmailAddress: `you@localhost`, + InviterIdentityId: `yourIdentityId`, + ApiKeyId: `temp`, + RegistrationMethod: `invite`, } mockPendingInviteAccounts.push(account) } -export const promoteAccountByIdentityPoolId = async identityPoolId => { +export const promoteAccountByIdentityId = async identityId => { await resolveAfter(1500) const account = mockData.find( - account => account.identityPoolId === identityPoolId, + account => account.IdentityId === identityId, ) if (account === undefined) { throw new Error('Account not found!') @@ -130,31 +130,31 @@ export const promoteAccountByIdentityPoolId = async identityPoolId => { account.isAdmin = true } -export const approveAccountRequestByIdentityPoolId = async identityPoolId => { +export const approveAccountRequestByIdentityId = async identityId => { await resolveAfter(1500) - if (!mockPendingRequestAccounts.some(matchingIdentityId(identityPoolId))) { + if (!mockPendingRequestAccounts.some(matchingIdentityId(identityId))) { throw new Error('Account not found!') } - if (identityPoolId.endsWith('10')) { + if (identityId.endsWith('10')) { throw new Error('Something weird happened!') } - _.remove(mockPendingRequestAccounts, matchingIdentityId(identityPoolId)) + _.remove(mockPendingRequestAccounts, matchingIdentityId(identityId)) } -export const denyAccountRequestByIdentityPoolId = async identityPoolId => { +export const denyAccountRequestByIdentityId = async identityId => { await resolveAfter(1500) - if (!mockPendingRequestAccounts.some(matchingIdentityId(identityPoolId))) { + if (!mockPendingRequestAccounts.some(matchingIdentityId(identityId))) { throw new Error('Account not found!') } - if (identityPoolId.endsWith('10')) { + if (identityId.endsWith('10')) { throw new Error('Something weird happened!') } - _.remove(mockPendingRequestAccounts, matchingIdentityId(identityPoolId)) + _.remove(mockPendingRequestAccounts, matchingIdentityId(identityId)) } const matchingIdentityId = targetId => account => - account.identityPoolId === targetId + account.IdentityId === targetId From be626e94de573f515874f63d82430391a9c931d5 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Tue, 27 Aug 2019 11:38:01 -0700 Subject: [PATCH 33/45] Fix missing account field renames in mock service --- dev-portal/src/services/accounts.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dev-portal/src/services/accounts.js b/dev-portal/src/services/accounts.js index 5811a24ba..6015427ef 100644 --- a/dev-portal/src/services/accounts.js +++ b/dev-portal/src/services/accounts.js @@ -42,13 +42,13 @@ const mockData = (() => { })() const mockPendingRequestAccounts = _.cloneDeep(mockData).map( - ({ dateRegistered, ...rest }) => ({ ...rest, dateRequested: dateRegistered }), + ({ DateRegistered, ...rest }) => ({ ...rest, DateRequested: DateRegistered }), ) const mockPendingInviteAccounts = _.cloneDeep(mockData).map( - ({ dateRegistered, ...rest }) => ({ + ({ DateRegistered, ...rest }) => ({ ...rest, - dateInvited: dateRegistered, + DateInvited: DateRegistered, }), ) From 086a3b618b2186ca7bfa26c499d8972dbcd50ff0 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Thu, 29 Aug 2019 16:42:34 -0700 Subject: [PATCH 34/45] Format files to be changed in account-mgmt-backend branch --- dev-portal/src/components/NavBar.jsx | 75 +- dev-portal/src/index.js | 74 +- dev-portal/src/pages/Apis.jsx | 103 +- dev-portal/src/services/accounts.js | 20 +- dev-portal/src/services/api.js | 10 +- dev-portal/src/services/self.js | 48 +- lambdas/backend/express-route-handlers.js | 1206 +++++++++-------- lambdas/backend/express-server.js | 28 +- .../dev-portal-common/customers-controller.js | 547 ++++---- .../node_modules/dev-portal-common/pager.js | 66 +- lambdas/dump-v3-account-data/index.js | 56 +- 11 files changed, 1256 insertions(+), 977 deletions(-) diff --git a/dev-portal/src/components/NavBar.jsx b/dev-portal/src/components/NavBar.jsx index 6b8993a62..27e93eb57 100755 --- a/dev-portal/src/components/NavBar.jsx +++ b/dev-portal/src/components/NavBar.jsx @@ -5,7 +5,12 @@ import React from 'react' import { Link } from 'react-router-dom' import { Menu, Image } from 'semantic-ui-react' -import { isAdmin, isAuthenticated, logout, getLoginRedirectUrl } from 'services/self' +import { + isAdmin, + isAuthenticated, + logout, + getLoginRedirectUrl, +} from 'services/self' import { cognitoDomain, cognitoClientId } from '../services/api' @@ -20,44 +25,60 @@ import Register from './Register' export const NavBar = observer( class NavBar extends React.Component { - getCognitoUrl = (type) => { + getCognitoUrl = type => { let redirectUri = getLoginRedirectUrl() return `${cognitoDomain}/${type}?response_type=token&client_id=${cognitoClientId}&redirect_uri=${redirectUri}` } insertAuthMenu() { - return isAuthenticated() ? - ( - - {isAdmin() && Admin Panel} - My Dashboard - Sign Out - - ) : ( - - - Sign In + return isAuthenticated() ? ( + + {isAdmin() && ( + + Admin Panel - - - ) + )} + + My Dashboard + + + Sign Out + + + ) : ( + + + Sign In + + + + ) } render() { - return - - - {fragments.Home.title} - + return ( + + + + {fragments.Home.title} + - {fragments.GettingStarted.title} - {fragments.APIs.title} + + {fragments.GettingStarted.title} + + + {fragments.APIs.title} + - {this.insertAuthMenu()} - + {this.insertAuthMenu()} + + ) } - } + }, ) export default NavBar diff --git a/dev-portal/src/index.js b/dev-portal/src/index.js index 4c1305618..02a4c2914 100644 --- a/dev-portal/src/index.js +++ b/dev-portal/src/index.js @@ -1,8 +1,8 @@ // Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React from 'react'; -import ReactDOM from 'react-dom'; +import React from 'react' +import ReactDOM from 'react-dom' import { BrowserRouter, Route, Switch, Redirect } from 'react-router-dom' import * as queryString from 'query-string' @@ -28,7 +28,7 @@ import Feedback from './components/Feedback' import ApiSearch from './components/ApiSearch' import { isAdmin, init, login, logout } from 'services/self' -import './index.css'; +import './index.css' loadFragments() @@ -37,12 +37,13 @@ loadFragments() // user is not an administrator const feedbackEnabled = window.config.feedbackEnabled -export const AdminRoute = ({component: Component, ...rest}) => ( - ( - isAdmin() - ? - : - )} /> +export const AdminRoute = ({ component: Component, ...rest }) => ( + + isAdmin() ? : + } + /> ) class App extends React.Component { @@ -65,25 +66,41 @@ class App extends React.Component { - - { - const { action } = queryString.parse(window.location.search) - if (action === 'login') { + + { + const { action } = queryString.parse(window.location.search) + if (action === 'login') { + login() + } else if (action === 'logout') { + logout() + } + return + }} + /> + + + + + + + + { login() - } else if (action === 'logout') { + return + }} + /> + { logout() - } - return - }} /> - - - - - - - - { login(); return }} /> - { logout(); return }} /> + return + }} + />

Page not found

} />
{feedbackEnabled && } @@ -94,7 +111,4 @@ class App extends React.Component { } } -ReactDOM.render( - , - document.getElementById('root') -); +ReactDOM.render(, document.getElementById('root')) diff --git a/dev-portal/src/pages/Apis.jsx b/dev-portal/src/pages/Apis.jsx index c1022e93b..988d5e0f7 100644 --- a/dev-portal/src/pages/Apis.jsx +++ b/dev-portal/src/pages/Apis.jsx @@ -12,7 +12,7 @@ import { Container, Header, Icon } from 'semantic-ui-react' // services import { isAuthenticated } from 'services/self' -import { updateUsagePlansAndApisList, getApi } from 'services/api-catalog'; +import { updateUsagePlansAndApisList, getApi } from 'services/api-catalog' // components import ApisMenu from 'components/ApisMenu' @@ -22,13 +22,21 @@ import SwaggerLayoutPlugin from 'components/SwaggerUiLayout' import { store } from 'services/state.js' import { observer } from 'mobx-react' -export default observer(class ApisPage extends React.Component { - componentDidMount() { this.updateApi().then(() => updateUsagePlansAndApisList(true)) } - componentDidUpdate() { this.updateApi() } +export default observer( + class ApisPage extends React.Component { + componentDidMount() { + this.updateApi().then(() => updateUsagePlansAndApisList(true)) + } + componentDidUpdate() { + this.updateApi() + } - updateApi = () => { - return getApi(this.props.match.params.apiId || 'ANY', true, this.props.match.params.stage) - .then(api => { + updateApi = () => { + return getApi( + this.props.match.params.apiId || 'ANY', + true, + this.props.match.params.stage, + ).then(api => { if (api) { let swaggerUiConfig = { dom_id: '#swagger-ui-container', @@ -37,8 +45,8 @@ export default observer(class ApisPage extends React.Component { spec: api.swagger, onComplete: () => { if (store.apiKey) - uiHandler.preauthorizeApiKey("api_key", store.apiKey) - } + uiHandler.preauthorizeApiKey('api_key', store.apiKey) + }, } if (isAuthenticated()) { delete swaggerUiConfig.supportedSubmitMethods @@ -46,41 +54,54 @@ export default observer(class ApisPage extends React.Component { let uiHandler = SwaggerUI(swaggerUiConfig) } }) - } + } - render() { - let errorHeader - let errorBody + render() { + let errorHeader + let errorBody - if (store.apiList.loaded) { - if (!store.apiList.apiGateway.length && !store.apiList.generic.length) { - errorHeader = `No APIs Published` - errorBody = `Your administrator hasn't added any APIs to your account. Please contact them to publish an API.` - } else if (!store.api) { - errorHeader = `No Such API` - errorBody = `The selected API doesn't exist.` + if (store.apiList.loaded) { + if (!store.apiList.apiGateway.length && !store.apiList.generic.length) { + errorHeader = `No APIs Published` + errorBody = `Your administrator hasn't added any APIs to your account. Please contact them to publish an API.` + } else if (!store.api) { + errorHeader = `No Such API` + errorBody = `The selected API doesn't exist.` + } } - } - return ( -
- -
-
- {errorHeader && errorBody && ( - -
- - {errorHeader} -
- -

{errorBody}

-
-
- )} + return ( +
+ +
+
+ {errorHeader && errorBody && ( + +
+ + {errorHeader} +
+ +

{errorBody}

+
+
+ )} +
-
- ) - } -}) + ) + } + }, +) diff --git a/dev-portal/src/services/accounts.js b/dev-portal/src/services/accounts.js index 6015427ef..5e83df1ae 100644 --- a/dev-portal/src/services/accounts.js +++ b/dev-portal/src/services/accounts.js @@ -102,14 +102,14 @@ export const createInviteByEmail = async emailAddress => { await resolveAfter(1500) const account = { - IdentityId: `temp`, - UserId: `temp`, - EmailAddress: emailAddress, - DateInvited: new Date(Date.now()).toJSON(), - InviterEmailAddress: `you@localhost`, - InviterIdentityId: `yourIdentityId`, - ApiKeyId: `temp`, - RegistrationMethod: `invite`, + IdentityId: `temp`, + UserId: `temp`, + EmailAddress: emailAddress, + DateInvited: new Date(Date.now()).toJSON(), + InviterEmailAddress: `you@localhost`, + InviterIdentityId: `yourIdentityId`, + ApiKeyId: `temp`, + RegistrationMethod: `invite`, } mockPendingInviteAccounts.push(account) @@ -118,9 +118,7 @@ export const createInviteByEmail = async emailAddress => { export const promoteAccountByIdentityId = async identityId => { await resolveAfter(1500) - const account = mockData.find( - account => account.IdentityId === identityId, - ) + const account = mockData.find(account => account.IdentityId === identityId) if (account === undefined) { throw new Error('Account not found!') } diff --git a/dev-portal/src/services/api.js b/dev-portal/src/services/api.js index 3b96ae343..4726ddb4d 100644 --- a/dev-portal/src/services/api.js +++ b/dev-portal/src/services/api.js @@ -14,12 +14,16 @@ AWS.config.region = cognitoRegion let cachedClient -export function initApiGatewayClient({ accessKeyId, secretAccessKey, sessionToken } = {}) { +export function initApiGatewayClient({ + accessKeyId, + secretAccessKey, + sessionToken, +} = {}) { cachedClient = window.apigClientFactory.newClient({ accessKey: accessKeyId, secretKey: secretAccessKey, sessionToken: sessionToken, - region: awsRegion + region: awsRegion, }) window.apigw = cachedClient @@ -27,7 +31,7 @@ export function initApiGatewayClient({ accessKeyId, secretAccessKey, sessionToke export function apiGatewayClient() { if (cachedClient) return Promise.resolve(cachedClient) - + return new Promise(resolve => { const poller = setInterval(() => { if (cachedClient) { diff --git a/dev-portal/src/services/self.js b/dev-portal/src/services/self.js index a94e45fee..1051abfb8 100644 --- a/dev-portal/src/services/self.js +++ b/dev-portal/src/services/self.js @@ -6,16 +6,28 @@ import AWS from 'aws-sdk' // services import { store } from 'services/state' import { updateAllUserData } from 'services/api-catalog' -import { initApiGatewayClient, apiGatewayClient, cognitoDomain, cognitoIdentityPoolId, cognitoUserPoolId, cognitoClientId, cognitoRegion } from 'services/api' -import * as jwt_decode from "jwt-decode"; +import { + initApiGatewayClient, + apiGatewayClient, + cognitoDomain, + cognitoIdentityPoolId, + cognitoUserPoolId, + cognitoClientId, + cognitoRegion, +} from 'services/api' +import * as jwt_decode from 'jwt-decode' export function isAuthenticated() { return store.idToken } export function isAdmin() { - return store.idToken && - `${jwt_decode(store.idToken)['cognito:preferred_role']}`.includes('-CognitoAdminRole-') + return ( + store.idToken && + `${jwt_decode(store.idToken)['cognito:preferred_role']}`.includes( + '-CognitoAdminRole-', + ) + ) } export function init() { @@ -29,7 +41,8 @@ export function init() { try { idToken = localStorage.getItem(cognitoUserPoolId) - if (idToken) { // this `if` prevents console.error spam + if (idToken) { + // this `if` prevents console.error spam parsedToken = jwt_decode(idToken) valid = parsedToken.exp * 1000 > new Date() } @@ -60,7 +73,8 @@ export function login() { if (param[0] === 'access_token') accessToken = param[1] }) - if (idToken) { // we get both, we set both, but we only really care about the idToken + if (idToken) { + // we get both, we set both, but we only really care about the idToken username = jwt_decode(idToken)['cognito:username'] localStorage.setItem(cognitoUserPoolId, idToken) @@ -77,25 +91,26 @@ export function login() { }) } -export const getLoginRedirectUrl = () => `${window.location.protocol}//${window.location.host}/index.html?action=login` -export const getLogoutRedirectUrl = () => `${window.location.protocol}//${window.location.host}/index.html?action=logout` +export const getLoginRedirectUrl = () => + `${window.location.protocol}//${window.location.host}/index.html?action=login` +export const getLogoutRedirectUrl = () => + `${window.location.protocol}//${window.location.host}/index.html?action=logout` function setCredentials() { let preferred_role = jwt_decode(store.idToken)['cognito:preferred_role'] let params = { IdentityPoolId: cognitoIdentityPoolId, Logins: { - [`cognito-idp.${cognitoRegion}.amazonaws.com/${cognitoUserPoolId}`]: store.idToken - } + [`cognito-idp.${cognitoRegion}.amazonaws.com/${cognitoUserPoolId}`]: store.idToken, + }, } - if (preferred_role) - params.RoleArn = preferred_role + if (preferred_role) params.RoleArn = preferred_role AWS.config.credentials = new AWS.CognitoIdentityCredentials(params) return new Promise((resolve, reject) => { - AWS.config.credentials.refresh((error) => { + AWS.config.credentials.refresh(error => { if (error) { console.error(error) return reject(error) @@ -103,9 +118,10 @@ function setCredentials() { initApiGatewayClient(AWS.config.credentials) updateAllUserData() - - return apiGatewayClient() - .then(apiGatewayClient => apiGatewayClient.post('/signin', {}, {}, {})) + + return apiGatewayClient().then(apiGatewayClient => + apiGatewayClient.post('/signin', {}, {}, {}), + ) }) }) } diff --git a/lambdas/backend/express-route-handlers.js b/lambdas/backend/express-route-handlers.js index 9dc74851f..2cbcb4f64 100644 --- a/lambdas/backend/express-route-handlers.js +++ b/lambdas/backend/express-route-handlers.js @@ -14,592 +14,711 @@ const baseUrl = `http://${domain}/` const feedbackEnabled = !!process.env['FeedbackSnsTopicArn'] function getCognitoIdentityId(req) { - return req.apiGateway.event.requestContext.identity.cognitoIdentityId + return req.apiGateway.event.requestContext.identity.cognitoIdentityId } // strategy borrowed from: https://serverless-stack.com/chapters/mapping-cognito-identity-id-and-user-pool-id.html function getCognitoUserId(req) { - const authProvider = req.apiGateway.event.requestContext.identity.cognitoAuthenticationProvider; - - // Cognito authentication provider looks like: - // cognito-idp.us-east-1.amazonaws.com/us-east-1_xxxxxxxxx,cognito-idp.us-east-1.amazonaws.com/us-east-1_aaaaaaaaa:CognitoSignIn:qqqqqqqq-1111-2222-3333-rrrrrrrrrrrr - // Where us-east-1_aaaaaaaaa is the User Pool id - // And qqqqqqqq-1111-2222-3333-rrrrrrrrrrrr is the User Pool User Id - const parts = authProvider.split(':'), - userPoolIdParts = parts[parts.length - 3].split('/'), - userPoolId = userPoolIdParts[userPoolIdParts.length - 1], - userPoolUserId = parts[parts.length - 1] - - return userPoolUserId + const authProvider = + req.apiGateway.event.requestContext.identity.cognitoAuthenticationProvider + + // Cognito authentication provider looks like: + // cognito-idp.us-east-1.amazonaws.com/us-east-1_xxxxxxxxx,cognito-idp.us-east-1.amazonaws.com/us-east-1_aaaaaaaaa:CognitoSignIn:qqqqqqqq-1111-2222-3333-rrrrrrrrrrrr + // Where us-east-1_aaaaaaaaa is the User Pool id + // And qqqqqqqq-1111-2222-3333-rrrrrrrrrrrr is the User Pool User Id + const parts = authProvider.split(':'), + userPoolIdParts = parts[parts.length - 3].split('/'), + userPoolId = userPoolIdParts[userPoolIdParts.length - 1], + userPoolUserId = parts[parts.length - 1] + + return userPoolUserId } // this returns the key we use in the CustomersTable. It's constructed from the issuer field and the username when we // allow multiple identity providers, this will allow google's example@example.com to be distinguishable from // Cognito's or Facebook's example@example.com function getCognitoKey(req) { - return req.apiGateway.event.requestContext.authorizer.claims.iss + ' ' + getCognitoUsername(req) + return ( + req.apiGateway.event.requestContext.authorizer.claims.iss + + ' ' + + getCognitoUsername(req) + ) } function getUsagePlanFromCatalog(usagePlanId) { - return catalog() - .then((catalog) => catalog.apiGateway.find(usagePlan => usagePlan.id === usagePlanId)) + return catalog().then(catalog => + catalog.apiGateway.find(usagePlan => usagePlan.id === usagePlanId), + ) } function postSignIn(req, res) { - const cognitoIdentityId = getCognitoIdentityId(req) - console.log(`POST /signin for Cognito ID: ${cognitoIdentityId}`) - - const cognitoUserId = getCognitoUserId(req) + const cognitoIdentityId = getCognitoIdentityId(req) + console.log(`POST /signin for Cognito ID: ${cognitoIdentityId}`) + + const cognitoUserId = getCognitoUserId(req) + + function errFunc(data) { + console.log(`error: ${data}`) + res.status(500).json(data) + } + + // ensure an API Key exists for this customer and that the Cognito identity and API Key Id are tracked in DDB + customersController.getApiKeyForCustomer(cognitoIdentityId, errFunc, data => { + console.log(`Get Api Key data ${JSON.stringify(data)}`) + + if (data.items.length === 0) { + console.log(`No API Key found for customer ${cognitoIdentityId}`) + + customersController.createApiKey( + cognitoIdentityId, + cognitoUserId, + errFunc, + createData => { + console.log( + `Create API Key data: ${JSON.stringify(createData, null, 4)}`, + ) + const keyId = createData.id + + console.log(`Got key ID ${keyId}`) + + customersController + .ensureCustomerItem( + cognitoIdentityId, + cognitoUserId, + keyId, + errFunc, + ) + .then(() => res.status(200).json({})) + }, + ) + } else { + const keyId = data.items[0].id - function errFunc(data) { - console.log(`error: ${data}`) - res.status(500).json(data) + customersController + .ensureCustomerItem(cognitoIdentityId, cognitoUserId, keyId, errFunc) + .then(() => res.status(200).json({})) } - - // ensure an API Key exists for this customer and that the Cognito identity and API Key Id are tracked in DDB - customersController.getApiKeyForCustomer(cognitoIdentityId, errFunc, (data) => { - console.log(`Get Api Key data ${JSON.stringify(data)}`) - - if (data.items.length === 0) { - console.log(`No API Key found for customer ${cognitoIdentityId}`) - - customersController.createApiKey(cognitoIdentityId, cognitoUserId, errFunc, (createData) => { - console.log(`Create API Key data: ${JSON.stringify(createData, null, 4)}`) - const keyId = createData.id - - console.log(`Got key ID ${keyId}`) - - customersController.ensureCustomerItem(cognitoIdentityId, cognitoUserId, keyId, errFunc) - .then(() => res.status(200).json({})) - }) - } else { - const keyId = data.items[0].id - - customersController.ensureCustomerItem(cognitoIdentityId, cognitoUserId, keyId, errFunc) - .then(() => res.status(200).json({})) - } - }) + }) } function getCatalog(req, res) { - console.log(`GET /catalog for Cognito ID: ${getCognitoIdentityId(req)}`) - catalog() - .then(catalog => res.status(200).json(catalog)) - .catch(error => res.status(error.statusCode).json(error)) + console.log(`GET /catalog for Cognito ID: ${getCognitoIdentityId(req)}`) + catalog() + .then(catalog => res.status(200).json(catalog)) + .catch(error => res.status(error.statusCode).json(error)) } function getApiKey(req, res) { - const cognitoIdentityId = getCognitoIdentityId(req) - console.log(`GET /apikey for Cognito ID: ${cognitoIdentityId}`) + const cognitoIdentityId = getCognitoIdentityId(req) + console.log(`GET /apikey for Cognito ID: ${cognitoIdentityId}`) - function errFunc(data) { - console.log(`error: ${data}`) - res.status(500).json(data) - } + function errFunc(data) { + console.log(`error: ${data}`) + res.status(500).json(data) + } - customersController.getApiKeyForCustomer(cognitoIdentityId, errFunc, (data) => { - if (data.items.length === 0) { - res.status(404).json({ error: 'No API Key for customer' }) - } else { - const item = data.items[0] - const key = { - id: item.id, - value: item.value - } - res.status(200).json(key) - } - }) + customersController.getApiKeyForCustomer(cognitoIdentityId, errFunc, data => { + if (data.items.length === 0) { + res.status(404).json({ error: 'No API Key for customer' }) + } else { + const item = data.items[0] + const key = { + id: item.id, + value: item.value, + } + res.status(200).json(key) + } + }) } function getSubscriptions(req, res) { - let cognitoIdentityId = getCognitoIdentityId(req) - console.log(`GET /subscriptions for Cognito ID: ${cognitoIdentityId}`) - - function errFunc(data) { - console.log(`error: ${data}`) - res.status(500).json(data) - } - - customersController.getUsagePlansForCustomer(cognitoIdentityId, errFunc, (data) => { - res.status(200).json(data.items) - }) + let cognitoIdentityId = getCognitoIdentityId(req) + console.log(`GET /subscriptions for Cognito ID: ${cognitoIdentityId}`) + + function errFunc(data) { + console.log(`error: ${data}`) + res.status(500).json(data) + } + + customersController.getUsagePlansForCustomer( + cognitoIdentityId, + errFunc, + data => { + res.status(200).json(data.items) + }, + ) } function putSubscription(req, res) { - const cognitoIdentityId = getCognitoIdentityId(req) - console.log(`PUT /subscriptions for Cognito ID: ${cognitoIdentityId}`) - const usagePlanId = req.params.usagePlanId - - getUsagePlanFromCatalog(usagePlanId).then(async (catalogUsagePlan) => { - const isUsagePlanInCatalog = Boolean(catalogUsagePlan) - const apiGatewayUsagePlan = await exports.apigateway.getUsagePlan({ usagePlanId }).promise() + const cognitoIdentityId = getCognitoIdentityId(req) + console.log(`PUT /subscriptions for Cognito ID: ${cognitoIdentityId}`) + const usagePlanId = req.params.usagePlanId - function error(data) { - console.log(`error: ${data}`) - res.status(500).json(data) - } - - function success(data) { - res.status(201).json(data) - } - - // the usage plan doesn't exist - if (!isUsagePlanInCatalog) { - res.status(404).json({ error: 'Invalid Usage Plan ID' }) - // the usage plan exists, but 0 of its apis are visible - } else if(!catalogUsagePlan.apis.length) { - res.status(404).json({ error: 'Invalid Usage Plan ID' }) - // allow subscription if (the usage plan exists, at least 1 of its apis are visible) - } else { - customersController.subscribe(cognitoIdentityId, usagePlanId, error, success) - } - }) -} - -function getUsage(req, res) { - const cognitoIdentityId = getCognitoIdentityId(req) - console.log(`GET /usage for Cognito ID: ${cognitoIdentityId}`) - const usagePlanId = req.params.usagePlanId - - function errFunc(data) { - console.log(`error: ${data}`) - res.status(500).json(data) - } - - getUsagePlanFromCatalog(usagePlanId).then((usagePlan) => { - const isUsagePlanInCatalog = Boolean(usagePlan) - - // could error here if customer is not subscribed to usage plan, or save an extra request by just showing 0 usage - if (!isUsagePlanInCatalog) { - res.status(404).json({ error: 'Invalid Usage Plan ID' }) - } else { - customersController.getApiKeyForCustomer(cognitoIdentityId, errFunc, (data) => { - const keyId = data.items[0].id - - const params = { - endDate: req.query.end, - startDate: req.query.start, - usagePlanId, - keyId, - limit: 1000 - } - - exports.apigateway.getUsage(params, (err, usageData) => { - if (err) { - console.log(`get usage err ${JSON.stringify(err)}`) - errFunc(err) - } else { - console.log(`get usage data ${JSON.stringify(usageData)}`) - res.status(200).json(usageData) - } - }) - }) - } - }) -} - -function deleteSubscription(req, res) { - const cognitoIdentityId = getCognitoIdentityId(req) - console.log(`DELETE /subscriptions for Cognito ID: ${cognitoIdentityId}`) - const usagePlanId = req.params.usagePlanId + getUsagePlanFromCatalog(usagePlanId).then(async catalogUsagePlan => { + const isUsagePlanInCatalog = Boolean(catalogUsagePlan) + const apiGatewayUsagePlan = await exports.apigateway + .getUsagePlan({ usagePlanId }) + .promise() function error(data) { - console.log(`error: ${data}`) - res.status(500).json(data) + console.log(`error: ${data}`) + res.status(500).json(data) } function success(data) { - res.status(200).json(data) + res.status(201).json(data) } - getUsagePlanFromCatalog(usagePlanId).then((usagePlan) => { - const isUsagePlanInCatalog = Boolean(usagePlan) - - if (!isUsagePlanInCatalog) { - res.status(404).json({ error: 'Invalid Usage Plan ID'}) - } else { - customersController.unsubscribe(cognitoIdentityId, usagePlanId, error, success) - } - }) + // the usage plan doesn't exist + if (!isUsagePlanInCatalog) { + res.status(404).json({ error: 'Invalid Usage Plan ID' }) + // the usage plan exists, but 0 of its apis are visible + } else if (!catalogUsagePlan.apis.length) { + res.status(404).json({ error: 'Invalid Usage Plan ID' }) + // allow subscription if (the usage plan exists, at least 1 of its apis are visible) + } else { + customersController.subscribe( + cognitoIdentityId, + usagePlanId, + error, + success, + ) + } + }) } -function postMarketplaceConfirm(req, res) { - console.log(`POST /marketplace-confirm for Cognito ID: ${getCognitoIdentityId(req)}`) - // no auth - // this is the redirect URL for AWS Marketplace products - // i.e. https://YOUR_API_GATEWAY_API_ID.execute-api.us-east-1.amazonaws.com/prod/marketplace-confirm/[USAGE_PLAN_ID] - const marketplaceToken = req.body['x-amzn-marketplace-token'] - - if (marketplaceToken === null || marketplaceToken === undefined) { - console.log(`Couldn't find marketplace token. Event: ${util.inspect(req.apiGateway.event, { - depth: null, - colors: true - })}`) - res.status(400).json({ message: 'Missing AWS Marketplace token' }) - } +function getUsage(req, res) { + const cognitoIdentityId = getCognitoIdentityId(req) + console.log(`GET /usage for Cognito ID: ${cognitoIdentityId}`) + const usagePlanId = req.params.usagePlanId - console.log(`Marketplace token: ${marketplaceToken}`) - const usagePlanId = req.params.usagePlanId + function errFunc(data) { + console.log(`error: ${data}`) + res.status(500).json(data) + } - // WARNING: the redirect URL should be HTTPS as the token is subject to MITM attacks over HTTP. Token expires after 60min - // ideally this should be saved in a secure manner (i.e. DDB) until the subscription completes - const confirmUrl = `${baseUrl}?usagePlanId=${usagePlanId}&token=${marketplaceToken}` + getUsagePlanFromCatalog(usagePlanId).then(usagePlan => { + const isUsagePlanInCatalog = Boolean(usagePlan) - // redirect to the registration/login page - res.redirect(302, confirmUrl) + // could error here if customer is not subscribed to usage plan, or save an extra request by just showing 0 usage + if (!isUsagePlanInCatalog) { + res.status(404).json({ error: 'Invalid Usage Plan ID' }) + } else { + customersController.getApiKeyForCustomer( + cognitoIdentityId, + errFunc, + data => { + const keyId = data.items[0].id + + const params = { + endDate: req.query.end, + startDate: req.query.start, + usagePlanId, + keyId, + limit: 1000, + } + + exports.apigateway.getUsage(params, (err, usageData) => { + if (err) { + console.log(`get usage err ${JSON.stringify(err)}`) + errFunc(err) + } else { + console.log(`get usage data ${JSON.stringify(usageData)}`) + res.status(200).json(usageData) + } + }) + }, + ) + } + }) } -function putMarketplaceSubscription(req, res) { - const cognitoIdentityId = getCognitoIdentityId(req) - console.log(`PUT /marketplace-subscriptions/:usagePlanId for Cognito ID: ${cognitoIdentityId}`) +function deleteSubscription(req, res) { + const cognitoIdentityId = getCognitoIdentityId(req) + console.log(`DELETE /subscriptions for Cognito ID: ${cognitoIdentityId}`) + const usagePlanId = req.params.usagePlanId - const marketplaceToken = req.body.token - const usagePlanId = req.params.usagePlanId - console.log(`Marketplace token: ${marketplaceToken} usage plan id: ${usagePlanId}`) - console.log(`cognito id: ${cognitoIdentityId}`) + function error(data) { + console.log(`error: ${data}`) + res.status(500).json(data) + } - function error(data) { - console.log(`error: ${data}`) - res.status(500).json(data) - } + function success(data) { + res.status(200).json(data) + } - function success(data) { - res.status(200).json(data) - } + getUsagePlanFromCatalog(usagePlanId).then(usagePlan => { + const isUsagePlanInCatalog = Boolean(usagePlan) - function subscribeCustomerToUsagePlan(data) { - customersController.subscribe(cognitoIdentityId, usagePlanId, error, success) + if (!isUsagePlanInCatalog) { + res.status(404).json({ error: 'Invalid Usage Plan ID' }) + } else { + customersController.unsubscribe( + cognitoIdentityId, + usagePlanId, + error, + success, + ) } + }) +} - const marketplace = new AWS.MarketplaceMetering() +function postMarketplaceConfirm(req, res) { + console.log( + `POST /marketplace-confirm for Cognito ID: ${getCognitoIdentityId(req)}`, + ) + // no auth + // this is the redirect URL for AWS Marketplace products + // i.e. https://YOUR_API_GATEWAY_API_ID.execute-api.us-east-1.amazonaws.com/prod/marketplace-confirm/[USAGE_PLAN_ID] + const marketplaceToken = req.body['x-amzn-marketplace-token'] + + if (marketplaceToken === null || marketplaceToken === undefined) { + console.log( + `Couldn't find marketplace token. Event: ${util.inspect( + req.apiGateway.event, + { + depth: null, + colors: true, + }, + )}`, + ) + res.status(400).json({ message: 'Missing AWS Marketplace token' }) + } + + console.log(`Marketplace token: ${marketplaceToken}`) + const usagePlanId = req.params.usagePlanId + + // WARNING: the redirect URL should be HTTPS as the token is subject to MITM attacks over HTTP. Token expires after 60min + // ideally this should be saved in a secure manner (i.e. DDB) until the subscription completes + const confirmUrl = `${baseUrl}?usagePlanId=${usagePlanId}&token=${marketplaceToken}` + + // redirect to the registration/login page + res.redirect(302, confirmUrl) +} - const params = { - RegistrationToken: marketplaceToken +function putMarketplaceSubscription(req, res) { + const cognitoIdentityId = getCognitoIdentityId(req) + console.log( + `PUT /marketplace-subscriptions/:usagePlanId for Cognito ID: ${cognitoIdentityId}`, + ) + + const marketplaceToken = req.body.token + const usagePlanId = req.params.usagePlanId + console.log( + `Marketplace token: ${marketplaceToken} usage plan id: ${usagePlanId}`, + ) + console.log(`cognito id: ${cognitoIdentityId}`) + + function error(data) { + console.log(`error: ${data}`) + res.status(500).json(data) + } + + function success(data) { + res.status(200).json(data) + } + + function subscribeCustomerToUsagePlan(data) { + customersController.subscribe( + cognitoIdentityId, + usagePlanId, + error, + success, + ) + } + + const marketplace = new AWS.MarketplaceMetering() + + const params = { + RegistrationToken: marketplaceToken, + } + + // call MMS to crack token into marketpltestSingleAccountId_apiKeysConfigace customer ID and product code + marketplace.resolveCustomer(params, (err, data) => { + if (err) { + console.log(`marketplace error: ${JSON.stringify(err)}`) + res.status(400).json(err.message) + } else { + console.log(`marketplace data: ${JSON.stringify(data)}`) + + // persist the marketplaceCustomerId in DDB + // this is used when the subscription listener receives the subscribe notification + const marketplaceCustomerId = data.CustomerIdentifier + customersController.updateCustomerMarketplaceId( + cognitoIdentityId, + marketplaceCustomerId, + error, + subscribeCustomerToUsagePlan, + ) } - - // call MMS to crack token into marketpltestSingleAccountId_apiKeysConfigace customer ID and product code - marketplace.resolveCustomer(params, (err, data) => { - if (err) { - console.log(`marketplace error: ${JSON.stringify(err)}`) - res.status(400).json(err.message) - } else { - console.log(`marketplace data: ${JSON.stringify(data)}`) - - // persist the marketplaceCustomerId in DDB - // this is used when the subscription listener receives the subscribe notification - const marketplaceCustomerId = data.CustomerIdentifier - customersController.updateCustomerMarketplaceId(cognitoIdentityId, marketplaceCustomerId, error, subscribeCustomerToUsagePlan) - } - }) + }) } function getFeedback(req, res) { - console.log(`GET /feedback for Cognito ID: ${getCognitoIdentityId(req)}`) - - if (!feedbackEnabled) { - res.status(401).json("Customer feedback not enabled") - } else { - feedbackController.fetchFeedback() - .then(feedback => { - res.status(200).json(feedback) - }) - .catch(err => { - console.log(`error: ${err}`) - res.status(500).json(err) - }) - } + console.log(`GET /feedback for Cognito ID: ${getCognitoIdentityId(req)}`) + + if (!feedbackEnabled) { + res.status(401).json('Customer feedback not enabled') + } else { + feedbackController + .fetchFeedback() + .then(feedback => { + res.status(200).json(feedback) + }) + .catch(err => { + console.log(`error: ${err}`) + res.status(500).json(err) + }) + } } function postFeedback(req, res) { - const cognitoIdentityId = getCognitoIdentityId(req) - console.log(`POST /feedback for Cognito ID: ${cognitoIdentityId}`) - - if (!feedbackEnabled) { - res.status(401).json("Customer feedback not enabled") - } else { - feedbackController.submitFeedback(cognitoIdentityId, req.body.message) - .then(() => res.status(200).json('success')) - .catch((err) => res.status(500).json(err)) - } + const cognitoIdentityId = getCognitoIdentityId(req) + console.log(`POST /feedback for Cognito ID: ${cognitoIdentityId}`) + + if (!feedbackEnabled) { + res.status(401).json('Customer feedback not enabled') + } else { + feedbackController + .submitFeedback(cognitoIdentityId, req.body.message) + .then(() => res.status(200).json('success')) + .catch(err => res.status(500).json(err)) + } } function findApiInCatalog(restApiId, stageName, catalog) { - let foundApi = null + let foundApi = null - // forEach here is inefficient; can't terminate early - catalog.apiGateway.forEach((usagePlan) => { - usagePlan.apis.forEach((api) => { - if (api.id === restApiId && api.stage === stageName) - foundApi = api - }) + // forEach here is inefficient; can't terminate early + catalog.apiGateway.forEach(usagePlan => { + usagePlan.apis.forEach(api => { + if (api.id === restApiId && api.stage === stageName) foundApi = api }) + }) - Object.keys(catalog.generic).forEach((genericKey) => { - let api = catalog.generic[genericKey] - if(api.apiId === restApiId && api.stage === stageName) - foundApi = api - }) + Object.keys(catalog.generic).forEach(genericKey => { + let api = catalog.generic[genericKey] + if (api.apiId === restApiId && api.stage === stageName) foundApi = api + }) - return foundApi + return foundApi } async function getSdk(req, res) { - console.log(`GET /catalog/${req.params.id}/sdk for Cognito ID: ${getCognitoIdentityId(req)}`) - - // note that we only return an SDK if the API is in the catalog - // this is important because the lambda function has permission to fetch any API's SDK - // we don't want to leak customer API shapes if they have privileged APIs not in the catalog - let restApiId = req.params.id.split('_')[0], - stageName = req.params.id.split('_')[1], - catalogObject = findApiInCatalog(restApiId, stageName, await catalog()) - - if(!catalogObject) { - res.status(400).json({ message: `API with ID (${restApiId}) and Stage (${stageName}) could not be found.` }) - } else if(!catalogObject.sdkGeneration) { - res.status(400).json({ message: `API with ID (${restApiId}) and Stage (${stageName}) is not enabled for SDK generation.` }) - } else { - let parameters = req.query.parameters - if (typeof parameters === 'string') { - try { parameters = JSON.parse(parameters) } catch (e) { - return res.status(400).json({ message: `Input parameters for API with ID (${restApiId}) and Stage (${stageName}) were a string, but not parsable JSON: ${parameters}` }) - } - } - console.log(req.query.parameters) - console.log(parameters) - let resultsBuffer = (await exports.apigateway.getSdk({ - restApiId, - sdkType: req.query.sdkType, - stageName, - parameters - }).promise()).body - - const datauri = new Datauri(); - datauri.format('.zip', resultsBuffer) - - res.send(datauri.content) + console.log( + `GET /catalog/${req.params.id}/sdk for Cognito ID: ${getCognitoIdentityId( + req, + )}`, + ) + + // note that we only return an SDK if the API is in the catalog + // this is important because the lambda function has permission to fetch any API's SDK + // we don't want to leak customer API shapes if they have privileged APIs not in the catalog + let restApiId = req.params.id.split('_')[0], + stageName = req.params.id.split('_')[1], + catalogObject = findApiInCatalog(restApiId, stageName, await catalog()) + + if (!catalogObject) { + res + .status(400) + .json({ + message: `API with ID (${restApiId}) and Stage (${stageName}) could not be found.`, + }) + } else if (!catalogObject.sdkGeneration) { + res + .status(400) + .json({ + message: `API with ID (${restApiId}) and Stage (${stageName}) is not enabled for SDK generation.`, + }) + } else { + let parameters = req.query.parameters + if (typeof parameters === 'string') { + try { + parameters = JSON.parse(parameters) + } catch (e) { + return res + .status(400) + .json({ + message: `Input parameters for API with ID (${restApiId}) and Stage (${stageName}) were a string, but not parsable JSON: ${parameters}`, + }) + } } + console.log(req.query.parameters) + console.log(parameters) + let resultsBuffer = (await exports.apigateway + .getSdk({ + restApiId, + sdkType: req.query.sdkType, + stageName, + parameters, + }) + .promise()).body + + const datauri = new Datauri() + datauri.format('.zip', resultsBuffer) + + res.send(datauri.content) + } } async function getAdminCatalogVisibility(req, res) { - console.log(`GET /admin/catalog/visibility for Cognito ID: ${getCognitoIdentityId(req)}`) - try { + console.log( + `GET /admin/catalog/visibility for Cognito ID: ${getCognitoIdentityId( + req, + )}`, + ) + try { + let visibility = { apiGateway: [] }, + catalogObject = await catalog(), + apis = (await exports.apigateway.getRestApis().promise()).items + + console.log(`network request: ${JSON.stringify(apis, null, 4)}`) + console.log(`apis: ${JSON.stringify(apis, null, 4)}`) + + let promises = [] + apis.forEach(api => { + promises.push( + exports.apigateway + .getStages({ restApiId: api.id }) + .promise() + .then(response => response.item) + .then(stages => + stages.forEach(stage => + visibility.apiGateway.push({ + id: api.id, + name: api.name, + stage: stage.stageName, + visibility: false, + }), + ), + ), + ) + }) + await Promise.all(promises) - let visibility = { apiGateway: [] }, - catalogObject = await catalog(), - apis = (await exports.apigateway.getRestApis().promise()).items - - console.log(`network request: ${JSON.stringify(apis, null, 4)}`) - console.log(`apis: ${JSON.stringify(apis, null, 4)}`) - - let promises = [] - apis.forEach((api) => { - promises.push( - exports.apigateway.getStages({ restApiId: api.id }).promise() - .then((response) => response.item) - .then((stages) => stages.forEach(stage => visibility.apiGateway.push({ - id: api.id, - name: api.name, - stage: stage.stageName, - visibility: false - }))) - ) - }) - await Promise.all(promises) - - console.log(`visibility: ${JSON.stringify(visibility, null, 4)}`) - - // mark every api gateway managed api-stage in the catalog as visible - catalogObject.apiGateway.forEach((usagePlan) => { - usagePlan.apis.forEach((api) => { - visibility.apiGateway.map((apiEntry) => { - if(apiEntry.id === api.id && apiEntry.stage === api.stage) { - apiEntry.visibility = true - apiEntry.sdkGeneration = api.sdkGeneration || false - } - - return apiEntry - }) - }) - }) + console.log(`visibility: ${JSON.stringify(visibility, null, 4)}`) + + // mark every api gateway managed api-stage in the catalog as visible + catalogObject.apiGateway.forEach(usagePlan => { + usagePlan.apis.forEach(api => { + visibility.apiGateway.map(apiEntry => { + if (apiEntry.id === api.id && apiEntry.stage === api.stage) { + apiEntry.visibility = true + apiEntry.sdkGeneration = api.sdkGeneration || false + } - let usagePlans = await getAllUsagePlans(exports.apigateway) - - // In the case of apiGateway APIs, the client doesn't know if there are usage plan associated or not - // so we need to provide that information. This can't be merged with the above loop: - // (catalogObject.apiGateway.forEach((usagePlan) => ... - // because the catalog only contains *visible* apis, and this loop needs to record the subscribability - // of both visible and non-visible APIs. - visibility.apiGateway.map((apiEntry) => { - apiEntry.subscribable = false - - usagePlans.forEach((usagePlan) => { - usagePlan.apiStages.forEach((apiStage) => { - if(apiEntry.id === apiStage.apiId && apiEntry.stage === apiStage.stage) { - apiEntry.subscribable = true - apiEntry.usagePlanId = usagePlan.id - apiEntry.usagePlanName = usagePlan.name - } - - apiEntry.sdkGeneration = !!apiEntry.sdkGeneration - }) - }) - - return apiEntry + return apiEntry }) + }) + }) - // mark every api in the generic catalog as visible - catalogObject.generic.forEach((catalogEntry) => { - if(!visibility.generic) { - visibility.generic = {} - } + let usagePlans = await getAllUsagePlans(exports.apigateway) + + // In the case of apiGateway APIs, the client doesn't know if there are usage plan associated or not + // so we need to provide that information. This can't be merged with the above loop: + // (catalogObject.apiGateway.forEach((usagePlan) => ... + // because the catalog only contains *visible* apis, and this loop needs to record the subscribability + // of both visible and non-visible APIs. + visibility.apiGateway.map(apiEntry => { + apiEntry.subscribable = false + + usagePlans.forEach(usagePlan => { + usagePlan.apiStages.forEach(apiStage => { + if ( + apiEntry.id === apiStage.apiId && + apiEntry.stage === apiStage.stage + ) { + apiEntry.subscribable = true + apiEntry.usagePlanId = usagePlan.id + apiEntry.usagePlanName = usagePlan.name + } + + apiEntry.sdkGeneration = !!apiEntry.sdkGeneration + }) + }) - visibility.generic[catalogEntry.id] = { - visibility: true, - name: (catalogEntry.swagger && catalogEntry.swagger.info && catalogEntry.swagger.info.title) || 'Untitled' - } + return apiEntry + }) - if(catalogEntry.stage) - visibility.generic[catalogEntry.id].stage = catalogEntry.stage - if(catalogEntry.apiId) - visibility.generic[catalogEntry.id].apiId = catalogEntry.apiId - if(catalogEntry.sdkGeneration !== undefined) { - visibility.apiGateway.map((api) => { - console.log(api) - console.log(catalogEntry) - if(api.id === catalogEntry.apiId && api.stage === catalogEntry.stage) { - api.sdkGeneration = catalogEntry.sdkGeneration - } - return api - }) - } + // mark every api in the generic catalog as visible + catalogObject.generic.forEach(catalogEntry => { + if (!visibility.generic) { + visibility.generic = {} + } + + visibility.generic[catalogEntry.id] = { + visibility: true, + name: + (catalogEntry.swagger && + catalogEntry.swagger.info && + catalogEntry.swagger.info.title) || + 'Untitled', + } + + if (catalogEntry.stage) + visibility.generic[catalogEntry.id].stage = catalogEntry.stage + if (catalogEntry.apiId) + visibility.generic[catalogEntry.id].apiId = catalogEntry.apiId + if (catalogEntry.sdkGeneration !== undefined) { + visibility.apiGateway.map(api => { + console.log(api) + console.log(catalogEntry) + if ( + api.id === catalogEntry.apiId && + api.stage === catalogEntry.stage + ) { + api.sdkGeneration = catalogEntry.sdkGeneration + } + return api }) + } + }) - res.status(200).json(visibility) - } catch (err) { - console.error(`error: ${ err.stack }`) + res.status(200).json(visibility) + } catch (err) { + console.error(`error: ${err.stack}`) - // TODO: Should this be 'error' or 'message'? - res.status(500).json({ error: 'Internal Server Error' }) - } + // TODO: Should this be 'error' or 'message'? + res.status(500).json({ error: 'Internal Server Error' }) + } } async function postAdminCatalogVisibility(req, res) { - console.log(`POST /admin-catalog-visibility for Cognito ID: ${getCognitoIdentityId(req)}`) - - // for apigateway managed APIs, provide "apiId_stageName" - // in the apiKey field - if(req.body && req.body.apiKey) { - // try { - let swagger = await exports.apigateway.getExport({ - restApiId: req.body.apiKey.split('_')[0], - stageName: req.body.apiKey.split('_')[1], - exportType: 'swagger', - parameters: { - "extensions": "apigateway" - } - }).promise() - - console.log('swagger: ', swagger.body) - console.log('subscribable: ', req.body.subscribable) - - let params - if (req.body.subscribable === 'true' || req.body.subscribable === true) { - params = { - Bucket: process.env.StaticBucketName, - Key: `catalog/${req.body.apiKey}.json`, - Body: swagger.body - } - - } else if (req.body.subscribable === 'false') { - params = { - Bucket: process.env.StaticBucketName, - Key: `catalog/unsubscribable_${req.body.apiKey.split('_')[0]}_${req.body.apiKey.split('_')[1]}.json`, - Body: swagger.body - } - } - console.log('params: ', params) + console.log( + `POST /admin-catalog-visibility for Cognito ID: ${getCognitoIdentityId( + req, + )}`, + ) + + // for apigateway managed APIs, provide "apiId_stageName" + // in the apiKey field + if (req.body && req.body.apiKey) { + // try { + let swagger = await exports.apigateway + .getExport({ + restApiId: req.body.apiKey.split('_')[0], + stageName: req.body.apiKey.split('_')[1], + exportType: 'swagger', + parameters: { + extensions: 'apigateway', + }, + }) + .promise() + + console.log('swagger: ', swagger.body) + console.log('subscribable: ', req.body.subscribable) + + let params + if (req.body.subscribable === 'true' || req.body.subscribable === true) { + params = { + Bucket: process.env.StaticBucketName, + Key: `catalog/${req.body.apiKey}.json`, + Body: swagger.body, + } + } else if (req.body.subscribable === 'false') { + params = { + Bucket: process.env.StaticBucketName, + Key: `catalog/unsubscribable_${req.body.apiKey.split('_')[0]}_${ + req.body.apiKey.split('_')[1] + }.json`, + Body: swagger.body, + } + } + console.log('params: ', params) - await exports.s3.upload(params).promise() + await exports.s3.upload(params).promise() - res.status(200).json({ message: 'Success' }) - // } + res.status(200).json({ message: 'Success' }) + // } // for generic swagger, just provide the swagger body - } else if(req.body && req.body.swagger) { - try { - const swaggerObject = JSON.parse(req.body.swagger) - if(!(swaggerObject.info && swaggerObject.info.title)) { - res.status(400).json({ message: 'Invalid input. API specification file must have a title.' }) - } - - console.log(`Given the input of type ${typeof swaggerObject}:`) - console.log(JSON.stringify(swaggerObject, null, 4)) - console.log(`I produced the hash: ${hash(swaggerObject)}`) - - let params = { - Bucket: process.env.StaticBucketName, - Key: `catalog/${hash(swaggerObject)}.json`, - Body: req.body.swagger - } - - await exports.s3.upload(params).promise() - - res.status(200).json({ message: 'Success' }) - } catch(error) { - console.error(error) - res.status(400).json({ message: 'Invalid input' }) - } - } else { - res.status(400).json({ message: 'Invalid input' }) + } else if (req.body && req.body.swagger) { + try { + const swaggerObject = JSON.parse(req.body.swagger) + if (!(swaggerObject.info && swaggerObject.info.title)) { + res + .status(400) + .json({ + message: 'Invalid input. API specification file must have a title.', + }) + } + + console.log(`Given the input of type ${typeof swaggerObject}:`) + console.log(JSON.stringify(swaggerObject, null, 4)) + console.log(`I produced the hash: ${hash(swaggerObject)}`) + + let params = { + Bucket: process.env.StaticBucketName, + Key: `catalog/${hash(swaggerObject)}.json`, + Body: req.body.swagger, + } + + await exports.s3.upload(params).promise() + + res.status(200).json({ message: 'Success' }) + } catch (error) { + console.error(error) + res.status(400).json({ message: 'Invalid input' }) } + } else { + res.status(400).json({ message: 'Invalid input' }) + } } async function deleteAdminCatalogVisibility(req, res) { - console.log(`DELETE /admin/catalog/visibility for Cognito ID: ${getCognitoIdentityId(req)}`) - const catalogObject = await catalog() - - // for apigateway managed APIs, provide "apiId_stageName" - // in the apiKey field - console.log('delete request params:', req.params) - if(req.params && req.params.id) { - let unsubscribable = true - - catalogObject.apiGateway.forEach((usagePlan) => { - usagePlan.apis.forEach((api) => { - if(api.id === req.params.id.split('_')[0] && api.stage === req.params.id.split('_')[1]) { - unsubscribable = false - } - }) - }) - - let params = { - Bucket: process.env.StaticBucketName, - // assumed: apiId_stageName.json is the only format - // no yaml, no autodetection based on file contents - Key: `catalog/${unsubscribable ? 'unsubscribable_' : ''}${req.params.id}.json` + console.log( + `DELETE /admin/catalog/visibility for Cognito ID: ${getCognitoIdentityId( + req, + )}`, + ) + const catalogObject = await catalog() + + // for apigateway managed APIs, provide "apiId_stageName" + // in the apiKey field + console.log('delete request params:', req.params) + if (req.params && req.params.id) { + let unsubscribable = true + + catalogObject.apiGateway.forEach(usagePlan => { + usagePlan.apis.forEach(api => { + if ( + api.id === req.params.id.split('_')[0] && + api.stage === req.params.id.split('_')[1] + ) { + unsubscribable = false } + }) + }) + + let params = { + Bucket: process.env.StaticBucketName, + // assumed: apiId_stageName.json is the only format + // no yaml, no autodetection based on file contents + Key: `catalog/${unsubscribable ? 'unsubscribable_' : ''}${ + req.params.id + }.json`, + } - await exports.s3.deleteObject(params).promise() + await exports.s3.deleteObject(params).promise() - res.status(200).json({ message: 'Success' }) + res.status(200).json({ message: 'Success' }) // for generic swagger, provide the hashed swagger body // in the id field - } else if(req.params && req.params.genericId) { - let params = { - Bucket: process.env.StaticBucketName, - Key: `catalog/${ req.params.genericId }.json` - } + } else if (req.params && req.params.genericId) { + let params = { + Bucket: process.env.StaticBucketName, + Key: `catalog/${req.params.genericId}.json`, + } - await exports.s3.deleteObject(params).promise() + await exports.s3.deleteObject(params).promise() - res.status(200).json({ message: 'Success' }) - } else { - res.status(400).json({ message: 'Invalid input' }) - } + res.status(200).json({ message: 'Success' }) + } else { + res.status(400).json({ message: 'Invalid input' }) + } } - /** * Takes an API id (either in the api gateway manaaged APIID_STAGENAME format or the generic HASHEDID format) and a * parity (desired state) of the sdkGeneration flag for that API, and updates the file sdkGeneration.json in the static @@ -614,70 +733,85 @@ async function deleteAdminCatalogVisibility(req, res) { * @param {Object} res an express response object */ async function idempotentSdkGenerationUpdate(parity, id, res) { - let sdkGeneration = - JSON.parse((await exports.s3.getObject({ - Bucket: process.env.StaticBucketName, - Key: 'sdkGeneration.json' - }).promise()).Body) - - if (sdkGeneration[id] !== parity) { - sdkGeneration[id] = parity - - await exports.s3.upload({ - Bucket: process.env.StaticBucketName, - Key: 'sdkGeneration.json', - Body: JSON.stringify(sdkGeneration) - }).promise() - - // call catalogUpdater to build a fresh catalog.json that includes changes from sdkGeneration.json - await exports.lambda.invoke({ - FunctionName: process.env.CatalogUpdaterFunctionArn, - // this API would be more performant if we moved to 'Event' invocations, but then we couldn't signal to - // admins when the catalog updater failed to update the catalog; they'd see a 200 and then no change in - // behavior. - InvocationType: 'RequestResponse', - LogType: 'None' - }).promise() - - res.status(200).json({ message: 'Success' }) - } else { - res.status(200).json({ message: 'Success' }) - } + let sdkGeneration = JSON.parse( + (await exports.s3 + .getObject({ + Bucket: process.env.StaticBucketName, + Key: 'sdkGeneration.json', + }) + .promise()).Body, + ) + + if (sdkGeneration[id] !== parity) { + sdkGeneration[id] = parity + + await exports.s3 + .upload({ + Bucket: process.env.StaticBucketName, + Key: 'sdkGeneration.json', + Body: JSON.stringify(sdkGeneration), + }) + .promise() + + // call catalogUpdater to build a fresh catalog.json that includes changes from sdkGeneration.json + await exports.lambda + .invoke({ + FunctionName: process.env.CatalogUpdaterFunctionArn, + // this API would be more performant if we moved to 'Event' invocations, but then we couldn't signal to + // admins when the catalog updater failed to update the catalog; they'd see a 200 and then no change in + // behavior. + InvocationType: 'RequestResponse', + LogType: 'None', + }) + .promise() + + res.status(200).json({ message: 'Success' }) + } else { + res.status(200).json({ message: 'Success' }) + } } async function putAdminCatalogSdkGeneration(req, res) { - console.log(`PUT /admin/catalog/${req.params.id}/sdkGeneration for Cognito ID: ${getCognitoIdentityId(req)}`) + console.log( + `PUT /admin/catalog/${ + req.params.id + }/sdkGeneration for Cognito ID: ${getCognitoIdentityId(req)}`, + ) - await exports.idempotentSdkGenerationUpdate(true, req.params.id, res) + await exports.idempotentSdkGenerationUpdate(true, req.params.id, res) } async function deleteAdminCatalogSdkGeneration(req, res) { - console.log(`DELETE /admin/catalog/${req.params.id}/sdkGeneration for Cognito ID: ${getCognitoIdentityId(req)}`) + console.log( + `DELETE /admin/catalog/${ + req.params.id + }/sdkGeneration for Cognito ID: ${getCognitoIdentityId(req)}`, + ) - await exports.idempotentSdkGenerationUpdate(false, req.params.id, res) + await exports.idempotentSdkGenerationUpdate(false, req.params.id, res) } exports = module.exports = { - postSignIn, - getCatalog, - getApiKey, - getSubscriptions, - putSubscription, - getUsage, - deleteSubscription, - postMarketplaceConfirm, - putMarketplaceSubscription, - getFeedback, - postFeedback, - getSdk, - getAdminCatalogVisibility, - postAdminCatalogVisibility, - deleteAdminCatalogVisibility, - putAdminCatalogSdkGeneration, - deleteAdminCatalogSdkGeneration, - idempotentSdkGenerationUpdate, - s3: new AWS.S3(), - apigateway: new AWS.APIGateway(), - lambda: new AWS.Lambda(), - hash + postSignIn, + getCatalog, + getApiKey, + getSubscriptions, + putSubscription, + getUsage, + deleteSubscription, + postMarketplaceConfirm, + putMarketplaceSubscription, + getFeedback, + postFeedback, + getSdk, + getAdminCatalogVisibility, + postAdminCatalogVisibility, + deleteAdminCatalogVisibility, + putAdminCatalogSdkGeneration, + deleteAdminCatalogSdkGeneration, + idempotentSdkGenerationUpdate, + s3: new AWS.S3(), + apigateway: new AWS.APIGateway(), + lambda: new AWS.Lambda(), + hash, } diff --git a/lambdas/backend/express-server.js b/lambdas/backend/express-server.js index e4fe7a8a9..1ef21f565 100644 --- a/lambdas/backend/express-server.js +++ b/lambdas/backend/express-server.js @@ -1,7 +1,7 @@ // Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -'use strict'; +'use strict' const express = require('express') const bodyParser = require('body-parser') @@ -25,19 +25,33 @@ app.put('/subscriptions/:usagePlanId', handlers.putSubscription) app.get('/subscriptions/:usagePlanId/usage', handlers.getUsage) app.delete('/subscriptions/:usagePlanId', handlers.deleteSubscription) app.post('/marketplace-confirm/:usagePlanId', handlers.postMarketplaceConfirm) -app.put('/marketplace-subscriptions/:usagePlanId', handlers.putMarketplaceSubscription) +app.put( + '/marketplace-subscriptions/:usagePlanId', + handlers.putMarketplaceSubscription, +) app.get('/feedback', handlers.getFeedback) app.post('/feedback', handlers.postFeedback) app.get('/catalog/:id/sdk', handlers.getSdk) - // admin APIs app.get('/admin/catalog/visibility', handlers.getAdminCatalogVisibility) app.post('/admin/catalog/visibility', handlers.postAdminCatalogVisibility) -app.delete('/admin/catalog/visibility/:id', handlers.deleteAdminCatalogVisibility) -app.delete('/admin/catalog/visibility/generic/:genericId', handlers.deleteAdminCatalogVisibility) -app.put('/admin/catalog/:id/sdkGeneration', handlers.putAdminCatalogSdkGeneration) -app.delete('/admin/catalog/:id/sdkGeneration', handlers.deleteAdminCatalogSdkGeneration) +app.delete( + '/admin/catalog/visibility/:id', + handlers.deleteAdminCatalogVisibility, +) +app.delete( + '/admin/catalog/visibility/generic/:genericId', + handlers.deleteAdminCatalogVisibility, +) +app.put( + '/admin/catalog/:id/sdkGeneration', + handlers.putAdminCatalogSdkGeneration, +) +app.delete( + '/admin/catalog/:id/sdkGeneration', + handlers.deleteAdminCatalogSdkGeneration, +) // The aws-serverless-express library creates a server and listens on a Unix // Domain Socket for you, so you can remove the usual call to app.listen. diff --git a/lambdas/common-layer/nodejs/node_modules/dev-portal-common/customers-controller.js b/lambdas/common-layer/nodejs/node_modules/dev-portal-common/customers-controller.js index b6b7ee633..8f2bd698e 100644 --- a/lambdas/common-layer/nodejs/node_modules/dev-portal-common/customers-controller.js +++ b/lambdas/common-layer/nodejs/node_modules/dev-portal-common/customers-controller.js @@ -11,325 +11,350 @@ const apigateway = new AWS.APIGateway() const customersTable = process.env.CustomersTableName || 'DevPortalCustomers' function ensureCustomerItem(cognitoIdentityId, cognitoUserId, keyId, error) { - // ensure user is tracked in customer table - const getParams = { - TableName: customersTable, - Key: { - Id: cognitoIdentityId + // ensure user is tracked in customer table + const getParams = { + TableName: customersTable, + Key: { + Id: cognitoIdentityId, + }, + } + + return dynamoDb + .get(getParams) + .promise() + .then(data => { + // upsert old entries with user pool IDs as well as new entries + if (data.Item === undefined || data.Item.UserPoolId === undefined) { + const putParams = { + TableName: customersTable, + Item: { + Id: cognitoIdentityId, + UserPoolId: cognitoUserId, + ApiKeyId: keyId, + }, } - } - return dynamoDb.get(getParams).promise() - .then((data) => { - // upsert old entries with user pool IDs as well as new entries - if (data.Item === undefined || data.Item.UserPoolId === undefined) { - const putParams = { - TableName: customersTable, - Item: { - Id: cognitoIdentityId, - UserPoolId: cognitoUserId, - ApiKeyId: keyId - } - } - - return dynamoDb.put(putParams).promise() - .catch((customerErr) => error(customerErr)) - .then((customerData) => { - console.log(`Created new customer in ddb with id ${cognitoIdentityId}`) - return putParams.Item - }) - } else { - console.log(`Customer exists with id ${cognitoIdentityId}`) - return data.Item - } - }) - .catch((err) => { - console.error(err) - error(err) - }) + return dynamoDb + .put(putParams) + .promise() + .catch(customerErr => error(customerErr)) + .then(customerData => { + console.log( + `Created new customer in ddb with id ${cognitoIdentityId}`, + ) + return putParams.Item + }) + } else { + console.log(`Customer exists with id ${cognitoIdentityId}`) + return data.Item + } + }) + .catch(err => { + console.error(err) + error(err) + }) } function getCognitoIdentityId(marketplaceCustomerId, error, callback) { - const params = { - TableName: customersTable, - IndexName: "MarketplaceCustomerIdIndex", - KeyConditionExpression: "MarketplaceCustomerId = :customerId", - ExpressionAttributeValues: { - ":customerId": marketplaceCustomerId - }, - ProjectionExpression: "MarketplaceCustomerId, Id" + const params = { + TableName: customersTable, + IndexName: 'MarketplaceCustomerIdIndex', + KeyConditionExpression: 'MarketplaceCustomerId = :customerId', + ExpressionAttributeValues: { + ':customerId': marketplaceCustomerId, + }, + ProjectionExpression: 'MarketplaceCustomerId, Id', + } + dynamoDb.query(params, (err, data) => { + if (err) { + error(err) + } else if (data.Items === undefined || data.Items.length === 0) { + // no customer matching marketplaceCustomerId - this should be created during marketplace subscription redirect + error( + `No customer is registered in the developer portal for marketplace customer ID ${marketplaceCustomerId}`, + ) + } else { + callback(data.Items[0].Id) } - dynamoDb.query(params, (err, data) => { - if (err) { - error(err) - } else if (data.Items === undefined || data.Items.length === 0) { - // no customer matching marketplaceCustomerId - this should be created during marketplace subscription redirect - error(`No customer is registered in the developer portal for marketplace customer ID ${marketplaceCustomerId}`) - } else { - callback(data.Items[0].Id) - } - }) + }) } function subscribe(cognitoIdentityId, usagePlanId, errFunc, callback) { + getApiKeyForCustomer(cognitoIdentityId, errFunc, data => { + console.log(`Get Api Key data ${JSON.stringify(data)}`) - getApiKeyForCustomer(cognitoIdentityId, errFunc, (data) => { - console.log(`Get Api Key data ${JSON.stringify(data)}`) + if (data.items.length === 0) { + console.log(`No API Key found for customer ${cognitoIdentityId}`) - if (data.items.length === 0) { - console.log(`No API Key found for customer ${cognitoIdentityId}`) + createApiKey(cognitoIdentityId, errFunc, createData => { + console.log(`Create API Key data: ${createData}`) + const keyId = createData.id - createApiKey(cognitoIdentityId, errFunc, (createData) => { - console.log(`Create API Key data: ${createData}`) - const keyId = createData.id + console.log(`Got key ID ${keyId}`) - console.log(`Got key ID ${keyId}`) - - createUsagePlanKey(keyId, usagePlanId, errFunc, (createKeyData) => { - callback(createKeyData) - }) - }) - } else { - const keyId = data.items[0].id + createUsagePlanKey(keyId, usagePlanId, errFunc, createKeyData => { + callback(createKeyData) + }) + }) + } else { + const keyId = data.items[0].id - console.log(`Got key ID ${keyId}`) + console.log(`Got key ID ${keyId}`) - createUsagePlanKey(keyId, usagePlanId, errFunc, (createKeyData) => { - callback(createKeyData) - }) - } - }) + createUsagePlanKey(keyId, usagePlanId, errFunc, createKeyData => { + callback(createKeyData) + }) + } + }) } function unsubscribe(cognitoIdentityId, usagePlanId, error, success) { + getApiKeyForCustomer(cognitoIdentityId, error, data => { + console.log(`Get Api Key data ${JSON.stringify(data)}`) - getApiKeyForCustomer(cognitoIdentityId, error, (data) => { - console.log(`Get Api Key data ${JSON.stringify(data)}`) + if (data.items.length === 0) { + console.log(`No API Key found for customer ${cognitoIdentityId}`) - if (data.items.length === 0) { - console.log(`No API Key found for customer ${cognitoIdentityId}`) + error('Customer does not have an API Key') + } else { + const keyId = data.items[0].id - error('Customer does not have an API Key') - } else { - const keyId = data.items[0].id + console.log(`Found API Key for customer with ID ${keyId}`) - console.log(`Found API Key for customer with ID ${keyId}`) - - deleteUsagePlanKey(keyId, usagePlanId, error, (deleteData) => { - success(deleteData) - }) - } - }) + deleteUsagePlanKey(keyId, usagePlanId, error, deleteData => { + success(deleteData) + }) + } + }) } function createApiKey(cognitoIdentityId, cognitoUserId, error, callback) { - console.log(`Creating API Key for customer ${cognitoIdentityId}`) - - // set the name to the cognito identity ID so we can query API Key by the cognito identity - const params = { - description: `Dev Portal API Key for Identity Pool user ${cognitoIdentityId} / User Pool user ${cognitoUserId}`, - enabled: true, - generateDistinctId: true, - name: `${cognitoIdentityId}/${cognitoUserId}` + console.log(`Creating API Key for customer ${cognitoIdentityId}`) + + // set the name to the cognito identity ID so we can query API Key by the cognito identity + const params = { + description: `Dev Portal API Key for Identity Pool user ${cognitoIdentityId} / User Pool user ${cognitoUserId}`, + enabled: true, + generateDistinctId: true, + name: `${cognitoIdentityId}/${cognitoUserId}`, + } + + apigateway.createApiKey(params, (err, data) => { + if (err) { + console.log('createApiKey error', error) + error(err) + } else { + updateCustomerApiKeyId(cognitoIdentityId, data.id, error, () => + callback(data), + ) } - - apigateway.createApiKey(params, (err, data) => { - if (err) { - console.log('createApiKey error', error) - error(err) - } else { - updateCustomerApiKeyId(cognitoIdentityId, data.id, error, () => callback(data)) - } - }) + }) } function createUsagePlanKey(keyId, usagePlanId, error, callback) { - console.log(`Creating usage plan key for key id ${keyId} and usagePlanId ${usagePlanId}`) - - const params = { - keyId, - keyType: 'API_KEY', - usagePlanId - } - apigateway.createUsagePlanKey(params, (err, data) => { - if (err) error(err) - else callback(data) - }) + console.log( + `Creating usage plan key for key id ${keyId} and usagePlanId ${usagePlanId}`, + ) + + const params = { + keyId, + keyType: 'API_KEY', + usagePlanId, + } + apigateway.createUsagePlanKey(params, (err, data) => { + if (err) error(err) + else callback(data) + }) } function deleteUsagePlanKey(keyId, usagePlanId, error, callback) { - console.log(`Deleting usage plan key for key id ${keyId} and usagePlanId ${usagePlanId}`) - - const params = { - keyId, - usagePlanId - } - apigateway.deleteUsagePlanKey(params, (err, data) => { - if (err) error(err) - else callback(data) - }) + console.log( + `Deleting usage plan key for key id ${keyId} and usagePlanId ${usagePlanId}`, + ) + + const params = { + keyId, + usagePlanId, + } + apigateway.deleteUsagePlanKey(params, (err, data) => { + if (err) error(err) + else callback(data) + }) } function getApiKeyForCustomer(cognitoIdentityId, error, callback) { - console.log(`Getting API Key for customer ${cognitoIdentityId}`) - - const params = { - limit: 1, - includeValues: true, - nameQuery: cognitoIdentityId - } - apigateway.getApiKeys(params, (err, data) => { - if (err) error(err) - else callback(data) - }) + console.log(`Getting API Key for customer ${cognitoIdentityId}`) + + const params = { + limit: 1, + includeValues: true, + nameQuery: cognitoIdentityId, + } + apigateway.getApiKeys(params, (err, data) => { + if (err) error(err) + else callback(data) + }) } function getUsagePlansForCustomer(cognitoIdentityId, error, callback) { - console.log(`Getting API Key for customer ${cognitoIdentityId}`) - - getApiKeyForCustomer(cognitoIdentityId, error, (data) => { - if (data.items.length === 0) { - callback({data : {}}) - } else { - const keyId = data.items[0].id - const params = { - keyId, - limit: 1000 - } - getAllUsagePlans(apigateway, params) - .then(usagePlansData => callback({ items: usagePlansData })) - .catch(err => error(err)) - } - }) + console.log(`Getting API Key for customer ${cognitoIdentityId}`) + + getApiKeyForCustomer(cognitoIdentityId, error, data => { + if (data.items.length === 0) { + callback({ data: {} }) + } else { + const keyId = data.items[0].id + const params = { + keyId, + limit: 1000, + } + getAllUsagePlans(apigateway, params) + .then(usagePlansData => callback({ items: usagePlansData })) + .catch(err => error(err)) + } + }) } function getUsagePlanForProductCode(productCode, error, callback) { - console.log(`Getting Usage Plan for product ${productCode}`) - - // do a linear scan of usage plans for name matching productCode - var params = { - limit: 1000 - } - getAllUsagePlans(apigateway, params).then(usagePlans => { - console.log(`Got usage plans ${JSON.stringify(usagePlans)}`) - - // note: ensure that only one usage plan maps to a given marketplace product code - const usageplan = usagePlans.find(function (item) { - return item.productCode !== undefined && item.productCode === productCode - }) - if (usageplan !== undefined) { - console.log(`Found usage plan matching ${productCode}`) - callback(usageplan) - } else { - console.log(`Couldn't find usageplan matching product code ${productCode}`) - error(`Couldn't find usageplan matching product code ${productCode}`) - } - }).catch(err => error(err)) + console.log(`Getting Usage Plan for product ${productCode}`) + + // do a linear scan of usage plans for name matching productCode + var params = { + limit: 1000, + } + getAllUsagePlans(apigateway, params) + .then(usagePlans => { + console.log(`Got usage plans ${JSON.stringify(usagePlans)}`) + + // note: ensure that only one usage plan maps to a given marketplace product code + const usageplan = usagePlans.find(function(item) { + return ( + item.productCode !== undefined && item.productCode === productCode + ) + }) + if (usageplan !== undefined) { + console.log(`Found usage plan matching ${productCode}`) + callback(usageplan) + } else { + console.log( + `Couldn't find usageplan matching product code ${productCode}`, + ) + error(`Couldn't find usageplan matching product code ${productCode}`) + } + }) + .catch(err => error(err)) } -function updateCustomerMarketplaceId(cognitoIdentityId, marketplaceCustomerId, error, success) { - const dynamoDbParams = { - TableName: customersTable, - Key: { - Id: cognitoIdentityId - }, - UpdateExpression: 'set #a = :x', - ExpressionAttributeNames: { '#a': 'MarketplaceCustomerId' }, - ExpressionAttributeValues: { - ':x': marketplaceCustomerId - } - } - - // update DDB customer record with marketplace customer id - // and update API Gateway API Key with marketplace customer id - dynamoDb.update(dynamoDbParams, (dynamoDbErr) => { - if (dynamoDbErr) { - error(dynamoDbErr) - } else { - getApiKeyForCustomer(cognitoIdentityId, error, (data) => { - console.log(`Get Api Key data ${JSON.stringify(data)}`) +function updateCustomerMarketplaceId( + cognitoIdentityId, + marketplaceCustomerId, + error, + success, +) { + const dynamoDbParams = { + TableName: customersTable, + Key: { + Id: cognitoIdentityId, + }, + UpdateExpression: 'set #a = :x', + ExpressionAttributeNames: { '#a': 'MarketplaceCustomerId' }, + ExpressionAttributeValues: { + ':x': marketplaceCustomerId, + }, + } + + // update DDB customer record with marketplace customer id + // and update API Gateway API Key with marketplace customer id + dynamoDb.update(dynamoDbParams, dynamoDbErr => { + if (dynamoDbErr) { + error(dynamoDbErr) + } else { + getApiKeyForCustomer(cognitoIdentityId, error, data => { + console.log(`Get Api Key data ${JSON.stringify(data)}`) - if (data.items.length === 0) { - console.log(`No API Key found for customer ${cognitoIdentityId}`) + if (data.items.length === 0) { + console.log(`No API Key found for customer ${cognitoIdentityId}`) - createApiKey(cognitoIdentityId, errFunc, (createData) => { - console.log(`Create API Key data: ${createData}`) - const keyId = createData.id + createApiKey(cognitoIdentityId, errFunc, createData => { + console.log(`Create API Key data: ${createData}`) + const keyId = createData.id - console.log(`Got key ID ${keyId}`) + console.log(`Got key ID ${keyId}`) - updateApiKey(keyId, marketplaceCustomerId, error, (createKeyData) => { - success(createKeyData) - }) - }) - } else { - const keyId = data.items[0].id + updateApiKey(keyId, marketplaceCustomerId, error, createKeyData => { + success(createKeyData) + }) + }) + } else { + const keyId = data.items[0].id - console.log(`Got key ID ${keyId}`) + console.log(`Got key ID ${keyId}`) - updateApiKey(keyId, marketplaceCustomerId, error, (createKeyData) => { - success(createKeyData) - }) - } - }) + updateApiKey(keyId, marketplaceCustomerId, error, createKeyData => { + success(createKeyData) + }) } - }) + }) + } + }) } function updateApiKey(apiKeyId, marketplaceCustomerId, error, success) { - console.log(`Updating API Key ${apiKeyId} in API Gateway with marketplace customer ID`) - - // update API Gateway API Key with marketplace customer id to support metering - var params = { - apiKey: apiKeyId, - patchOperations: [ - { - op: 'replace', - path: '/customerId', - value: marketplaceCustomerId - } - ] - }; - apigateway.updateApiKey(params, function(err, data) { - if (err) error(err) - else success(data) - }); + console.log( + `Updating API Key ${apiKeyId} in API Gateway with marketplace customer ID`, + ) + + // update API Gateway API Key with marketplace customer id to support metering + var params = { + apiKey: apiKeyId, + patchOperations: [ + { + op: 'replace', + path: '/customerId', + value: marketplaceCustomerId, + }, + ], + } + apigateway.updateApiKey(params, function(err, data) { + if (err) error(err) + else success(data) + }) } function updateCustomerApiKeyId(cognitoIdentityId, apiKeyId, error, success) { - // update customer record with marketplace customer code - const dynamoDbParams = { - TableName: customersTable, - Key: { - Id: cognitoIdentityId - }, - UpdateExpression: 'set #a = :x', - ExpressionAttributeNames: { '#a': 'ApiKeyId' }, - ExpressionAttributeValues: { - ':x': apiKeyId - } + // update customer record with marketplace customer code + const dynamoDbParams = { + TableName: customersTable, + Key: { + Id: cognitoIdentityId, + }, + UpdateExpression: 'set #a = :x', + ExpressionAttributeNames: { '#a': 'ApiKeyId' }, + ExpressionAttributeValues: { + ':x': apiKeyId, + }, + } + + dynamoDb.update(dynamoDbParams, dynamoDbErr => { + if (dynamoDbErr) { + error(dynamoDbErr) + } else { + success() } - - dynamoDb.update(dynamoDbParams, (dynamoDbErr) => { - if (dynamoDbErr) { - error(dynamoDbErr) - } else { - success() - } - }) + }) } module.exports = { - ensureCustomerItem, - subscribe, - unsubscribe, - createApiKey, - createUsagePlanKey, - deleteUsagePlanKey, - getApiKeyForCustomer, - getUsagePlansForCustomer, - getUsagePlanForProductCode, - updateCustomerMarketplaceId, - getCognitoIdentityId, - dynamoDb + ensureCustomerItem, + subscribe, + unsubscribe, + createApiKey, + createUsagePlanKey, + deleteUsagePlanKey, + getApiKeyForCustomer, + getUsagePlansForCustomer, + getUsagePlanForProductCode, + updateCustomerMarketplaceId, + getCognitoIdentityId, + dynamoDb, } diff --git a/lambdas/common-layer/nodejs/node_modules/dev-portal-common/pager.js b/lambdas/common-layer/nodejs/node_modules/dev-portal-common/pager.js index afc7e6c32..174baa8eb 100644 --- a/lambdas/common-layer/nodejs/node_modules/dev-portal-common/pager.js +++ b/lambdas/common-layer/nodejs/node_modules/dev-portal-common/pager.js @@ -14,41 +14,61 @@ * fetch; or * - a falsy value, to indicate that no next page should be fetched. */ -const fetchAllItems = async ({ fetchPage, commonParams, selectItems, getNextPageParams }) => { +const fetchAllItems = async ({ + fetchPage, + commonParams, + selectItems, + getNextPageParams, +}) => { const firstPage = await fetchPage(commonParams).promise() const items = [...selectItems(firstPage)] let nextPageParams = getNextPageParams(firstPage) while (nextPageParams) { - const page = await fetchPage({ ...commonParams, ...nextPageParams }).promise() + const page = await fetchPage({ + ...commonParams, + ...nextPageParams, + }).promise() items.push(...selectItems(page)) nextPageParams = getNextPageParams(page) } return items } -const fetchUsersInCognitoUserPoolGroup = ({ cognitoClient, userPoolId, groupName }) => fetchAllItems({ - fetchPage: params => cognitoClient.listUsersInGroup(params), - commonParams: { UserPoolId: userPoolId, GroupName: groupName }, - selectItems: page => page.Users, - getNextPageParams: page => page.NextToken && { NextToken: page.NextToken }, -}) +const fetchUsersInCognitoUserPoolGroup = ({ + cognitoClient, + userPoolId, + groupName, +}) => + fetchAllItems({ + fetchPage: params => cognitoClient.listUsersInGroup(params), + commonParams: { UserPoolId: userPoolId, GroupName: groupName }, + selectItems: page => page.Users, + getNextPageParams: page => page.NextToken && { NextToken: page.NextToken }, + }) -const fetchUsersInCognitoUserPool = ({ cognitoClient, userPoolId }) => fetchAllItems({ - fetchPage: params => cognitoClient.listUsers(params), - commonParams: { UserPoolId: userPoolId }, - selectItems: page => page.Users, - getNextPageParams: page => page.NextToken && { NextToken: page.NextToken } -}) +const fetchUsersInCognitoUserPool = ({ cognitoClient, userPoolId }) => + fetchAllItems({ + fetchPage: params => cognitoClient.listUsers(params), + commonParams: { UserPoolId: userPoolId }, + selectItems: page => page.Users, + getNextPageParams: page => page.NextToken && { NextToken: page.NextToken }, + }) -const fetchItemsInDynamoDbTable = ({ dynamoDbClient, tableName, extraParams = {} }) => fetchAllItems({ - fetchPage: params => dynamoDbClient.scan(params), - commonParams: { - ...extraParams, - TableName: tableName, - }, - selectItems: page => page.Items, - getNextPageParams: page => page.LastEvaluatedKey && { ExclusiveStartKey: page.LastEvaluatedKey }, -}) +const fetchItemsInDynamoDbTable = ({ + dynamoDbClient, + tableName, + extraParams = {}, +}) => + fetchAllItems({ + fetchPage: params => dynamoDbClient.scan(params), + commonParams: { + ...extraParams, + TableName: tableName, + }, + selectItems: page => page.Items, + getNextPageParams: page => + page.LastEvaluatedKey && { ExclusiveStartKey: page.LastEvaluatedKey }, + }) module.exports = { fetchAllItems, diff --git a/lambdas/dump-v3-account-data/index.js b/lambdas/dump-v3-account-data/index.js index 8efc4b70b..eca9667be 100644 --- a/lambdas/dump-v3-account-data/index.js +++ b/lambdas/dump-v3-account-data/index.js @@ -4,7 +4,7 @@ // Dumps account data (as defined in v3) from Cognito and DynamoDB, to be used // for migration to v4. Outputs tsv as a JSON string. -'use strict'; +'use strict' const AWS = require('aws-sdk') const pager = require('dev-portal-common/pager') @@ -21,8 +21,11 @@ const handler = async (_event, _context) => { console.log(`userPoolId: ${userPoolId}`) console.log(`adminsGroupName: ${adminsGroupName}`) - return await - fetchAccountData({ customersTableName, userPoolId, adminsGroupName }) + return await fetchAccountData({ + customersTableName, + userPoolId, + adminsGroupName, + }) } /** @@ -42,20 +45,28 @@ const ACCOUNT_DATA_FIELDS = [ */ const ACCOUNT_DATA_TSV_HEADER = ACCOUNT_DATA_FIELDS.join('\t') -const fetchAccountData = async ({ customersTableName, userPoolId, adminsGroupName }) => { - const [adminUserIds, accountsFromTable, usernamesByUserId] = - await Promise.all([ - fetchAdminUserIds({ userPoolId, adminsGroupName }), - fetchCustomersTableItems({ tableName: customersTableName }), - fetchUsernamesByUserId({ userPoolId }), - ]) +const fetchAccountData = async ({ + customersTableName, + userPoolId, + adminsGroupName, +}) => { + const [ + adminUserIds, + accountsFromTable, + usernamesByUserId, + ] = await Promise.all([ + fetchAdminUserIds({ userPoolId, adminsGroupName }), + fetchCustomersTableItems({ tableName: customersTableName }), + fetchUsernamesByUserId({ userPoolId }), + ]) let accounts = accountsFromTable accounts = insertIsAdmin({ accounts, adminUserIds }) accounts = insertUsernames({ accounts, usernamesByUserId }) - const accountsAsTsv = - accounts.map(account => accountDataAsTsv(account)).join('\n') + const accountsAsTsv = accounts + .map(account => accountDataAsTsv(account)) + .join('\n') return `${ACCOUNT_DATA_TSV_HEADER}\n${accountsAsTsv}\n` } @@ -64,8 +75,8 @@ const fetchAccountData = async ({ customersTableName, userPoolId, adminsGroupNam * * See https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_UserType.html. */ -const getCognitoUserSub = - user => user.Attributes.find(attribute => attribute.Name === 'sub').Value +const getCognitoUserSub = user => + user.Attributes.find(attribute => attribute.Name === 'sub').Value /** * Fetches the UserPoolIds of all users in the AdminsGroup. @@ -114,8 +125,8 @@ const fetchCustomersTableItems = async ({ tableName }) => { * Returns a copy of the `accounts` Array, except each element has `isAdmin` * set to `true` iff its UserPoolId is in the `adminUserIds` set. */ -const insertIsAdmin = ({ adminUserIds, accounts }) => accounts - .map(account => ({ +const insertIsAdmin = ({ adminUserIds, accounts }) => + accounts.map(account => ({ ...account, isAdmin: adminUserIds.has(account.userPoolId), })) @@ -124,8 +135,8 @@ const insertIsAdmin = ({ adminUserIds, accounts }) => accounts * Returns a copy of the `accounts` array, except each element has `username` * set to the username as specified in the `usernamesByUserId` Map. */ -const insertUsernames = ({ accounts, usernamesByUserId }) => accounts - .map(account => ({ +const insertUsernames = ({ accounts, usernamesByUserId }) => + accounts.map(account => ({ ...account, username: usernamesByUserId.get(account.userPoolId), })) @@ -139,12 +150,13 @@ const insertUsernames = ({ accounts, usernamesByUserId }) => accounts * * [1]: https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_UserType.html */ -const accountDataAsTsv = account => ACCOUNT_DATA_FIELDS - .map(key => key === 'emailAddress' ? '' : account[key].toString()) - .join('\t') +const accountDataAsTsv = account => + ACCOUNT_DATA_FIELDS.map(key => + key === 'emailAddress' ? '' : account[key].toString(), + ).join('\t') exports = module.exports = { cognitoClient: new AWS.CognitoIdentityServiceProvider(), dynamoDbClient: new AWS.DynamoDB(), - handler + handler, } From 32a58dfd2dfd16746c48dd824a01ec541d58e241 Mon Sep 17 00:00:00 2001 From: Alex Chew Date: Thu, 29 Aug 2019 17:07:24 -0700 Subject: [PATCH 35/45] Implement account management backend --- README.md | 2 +- cloudformation/template.yaml | 267 ++++- .../Admin/Accounts/AccountsTableColumns.jsx | 16 +- dev-portal/src/components/NavBar.jsx | 9 +- dev-portal/src/index.js | 13 +- .../pages/Admin/Accounts/PendingInvites.jsx | 43 +- .../pages/Admin/Accounts/PendingRequests.jsx | 8 +- .../Admin/Accounts/RegisteredAccounts.jsx | 6 +- .../Accounts/__tests__/AdminAccounts.jsx | 12 +- .../Accounts/__tests__/PendingInvites.jsx | 18 +- .../Accounts/__tests__/PendingRequests.jsx | 2 +- .../Accounts/__tests__/RegisteredAccounts.jsx | 24 +- dev-portal/src/pages/Apis.jsx | 24 +- dev-portal/src/services/accounts.js | 212 ++-- dev-portal/src/services/api.js | 33 +- dev-portal/src/services/self.js | 23 +- lambdas/backend/express-route-handlers.js | 277 +++-- lambdas/backend/express-server.js | 9 + .../index.js | 50 + .../index.js | 61 ++ .../__tests__/demo.js | 0 lambdas/cognito-pre-signup-trigger/index.js | 24 + .../index.js | 10 - .../dev-portal-common/customers-controller.js | 946 ++++++++++++++++-- .../dev-portal-common/get-cognito-user-sub.js | 7 + .../node_modules/dev-portal-common/get-env.js | 9 + .../dev-portal-common/inspect-stringify.js | 7 + .../node_modules/dev-portal-common/pager.js | 44 +- .../dev-portal-common/promisify2.js | 40 + lambdas/dump-v3-account-data/index.js | 9 +- 30 files changed, 1832 insertions(+), 373 deletions(-) create mode 100644 lambdas/cognito-post-authentication-trigger/index.js create mode 100644 lambdas/cognito-post-confirmation-trigger/index.js rename lambdas/{cognito-user-pools-confirmation-strategy => cognito-pre-signup-trigger}/__tests__/demo.js (100%) create mode 100644 lambdas/cognito-pre-signup-trigger/index.js delete mode 100644 lambdas/cognito-user-pools-confirmation-strategy/index.js create mode 100644 lambdas/common-layer/nodejs/node_modules/dev-portal-common/get-cognito-user-sub.js create mode 100644 lambdas/common-layer/nodejs/node_modules/dev-portal-common/get-env.js create mode 100644 lambdas/common-layer/nodejs/node_modules/dev-portal-common/inspect-stringify.js create mode 100644 lambdas/common-layer/nodejs/node_modules/dev-portal-common/promisify2.js diff --git a/README.md b/README.md index e6f98f28c..c84213342 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ aws cloudformation describe-stacks --query \ You can override any of the parameters in the template using the `--parameter-overrides key="value"` format. This will be necessary if you intend to deploy several instances of the developer portal or customize some of the features. You can see a full list of overridable parameters in `cloudformation/template.yaml` under the `Parameters` section. ## Registering Users -Users can self-register by clicking the 'Register' button in the developer portal. Cognito calls the `CognitoUserPoolsConfirmationStrategyFunction` to determine if the user is allowed to register themselves. By default, this function always accepts the user into the user pool, but you can customize the body of the function either in a local repository (followed by packaging and deploying) or in the lambda console. If you intend for the developer portal to be 'private' to some group of users (and not globally / freely accessible), you will need to write a lambda function that enforces your business logic for user registration. Documentation on this lambda function's use can be found [here](https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-pre-sign-up.html). +Users can self-register by clicking the 'Register' button in the developer portal. Cognito calls the `CognitoPreSignupTriggerFn` lambda to determine if the user is allowed to register themselves. By default, this function always accepts the user into the user pool, but you can customize the body of the function either in a local repository (followed by packaging and deploying) or in the lambda console. If you intend for the developer portal to be 'private' to some group of users (and not globally / freely accessible), you will need to write a lambda function that enforces your business logic for user registration. Documentation on this lambda function's use can be found [here](https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-pre-sign-up.html). ### Promoting a User to an Admin Admin users can manage what APIs are visible to normal users and whether or not SDK generation is enabled (per api) for normal users. To promote a user to an admin, go to the Cognito console in the account the developer portal is in, select User Pools, then select the correct User Pool for the dev portal. From there, choose Users and groups, click on the users' name, choose Add to group, and select the group named `STACK-NAMEAdminsGroup`. This user is now an admin; if they're currently logged in, they will have to log out and back in to receive admin credentials. diff --git a/cloudformation/template.yaml b/cloudformation/template.yaml index e38a88469..39249445b 100644 --- a/cloudformation/template.yaml +++ b/cloudformation/template.yaml @@ -56,6 +56,11 @@ Parameters: Description: The name of the DynamoDB Customers table. Default: 'DevPortalCustomers' + DevPortalPreLoginAccountsTableName: + Type: String + Description: The name of the DynamoDB PreLoginAccounts table. + Default: 'DevPortalPreLoginAccounts' + DevPortalAdminEmail: Type: String Description: The email address where user submitted feedback notifications get sent. @@ -628,6 +633,20 @@ Resources: ReadCapacityUnits: 5 WriteCapacityUnits: 5 + PreLoginAccountsTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: !Ref DevPortalPreLoginAccountsTableName + AttributeDefinitions: + - AttributeName: UserId + AttributeType: S + KeySchema: + - AttributeName: UserId + KeyType: HASH + ProvisionedThroughput: + ReadCapacityUnits: 5 + WriteCapacityUnits: 5 + FeedbackTable: Type: AWS::DynamoDB::Table Condition: EnableFeedbackSubmission @@ -727,6 +746,15 @@ Resources: - !Ref 'AWS::AccountId' - :table/ - !Ref CustomersTable + - Effect: Allow + Action: + - dynamodb:GetItem + - dynamodb:Query + - dynamodb:Scan + - dynamodb:PutItem + - dynamodb:UpdateItem + - dynamodb:DeleteItem + Resource: !GetAtt PreLoginAccountsTable.Arn - Effect: Allow Action: - dynamodb:Query @@ -767,8 +795,72 @@ Resources: - sns:Publish Resource: !Ref FeedbackSubmittedSNSTopic - !Ref 'AWS::NoValue' + - Effect: Allow + Action: + - cognito-idp:ListUsers + - cognito-idp:ListUsersInGroup + - cognito-idp:AdminAddUserToGroup + - cognito-idp:AdminCreateUser + - cognito-idp:AdminDeleteUser + - cognito-idp:AdminGetUser + - cognito-idp:AdminListGroupsForUser + Resource: !GetAtt CognitoUserPool.Arn + + CognitoPreSignupTriggerExecutionRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: sts:AssumeRole + Path: '/' + Policies: + - PolicyName: root + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - logs:CreateLogGroup + - logs:CreateLogStream + - logs:PutLogEvents + Resource: arn:aws:logs:*:*:* + + CognitoPostConfirmationTriggerExecutionRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: sts:AssumeRole + Path: '/' + Policies: + - PolicyName: root + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - logs:CreateLogGroup + - logs:CreateLogStream + - logs:PutLogEvents + Resource: arn:aws:logs:*:*:* + - Effect: Allow + Action: + - dynamodb:PutItem + Resource: !GetAtt PreLoginAccountsTable.Arn + - Effect: Allow + Action: + - cognito-idp:AdminAddUserToGroup + Resource: !GetAtt CognitoUserPool.Arn - CognitoStrategyLambdaExecutionRole: + CognitoPostAuthenticationTriggerExecutionRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: @@ -790,6 +882,20 @@ Resources: - logs:CreateLogStream - logs:PutLogEvents Resource: arn:aws:logs:*:*:* + - Effect: Allow + Action: + - dynamodb:Scan + - dynamodb:PutItem + Resource: !GetAtt CustomersTable.Arn + - Effect: Allow + Action: + - dynamodb:GetItem + - dynamodb:PutItem + Resource: !GetAtt PreLoginAccountsTable.Arn + - Effect: Allow + Action: + - cognito-idp:AdminAddUserToGroup + Resource: !GetAtt CognitoUserPool.Arn CatalogUpdaterLambdaExecutionRole: Type: AWS::IAM::Role @@ -958,11 +1064,41 @@ Resources: - !Ref ApiGatewayApi - '/*/*' - LambdaCognitoUserPoolExecutionPermission: + CognitoPreSignupTriggerFnExecutionPermission: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: !GetAtt CognitoPreSignupTriggerFn.Arn + Principal: cognito-idp.amazonaws.com + SourceArn: !Join + - '' + - - 'arn:aws:cognito-idp:' + - !Ref 'AWS::Region' + - ':' + - !Ref 'AWS::AccountId' + - ':userpool/' + - !Ref CognitoUserPool + + CognitoPostConfirmationTriggerFnExecutionPermission: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: !GetAtt CognitoPostConfirmationTriggerFn.Arn + Principal: cognito-idp.amazonaws.com + SourceArn: !Join + - '' + - - 'arn:aws:cognito-idp:' + - !Ref 'AWS::Region' + - ':' + - !Ref 'AWS::AccountId' + - ':userpool/' + - !Ref CognitoUserPool + + CognitoPostAuthenticationTriggerFnExecutionPermission: Type: AWS::Lambda::Permission Properties: Action: lambda:InvokeFunction - FunctionName: !GetAtt CognitoUserPoolsConfirmationStrategyFunction.Arn + FunctionName: !GetAtt CognitoPostAuthenticationTriggerFn.Arn Principal: cognito-idp.amazonaws.com SourceArn: !Join - '' @@ -1020,10 +1156,15 @@ Resources: WEBSITE_BUCKET_NAME: !Ref DevPortalSiteS3BucketName StaticBucketName: !Ref ArtifactsS3BucketName CustomersTableName: !Ref DevPortalCustomersTableName + PreLoginAccountsTableName: !Ref DevPortalPreLoginAccountsTableName CatalogUpdaterFunctionArn: !GetAtt CatalogUpdaterLambdaFunction.Arn FeedbackTableName: !Ref DevPortalFeedbackTableName FeedbackSnsTopicArn: !If [EnableFeedbackSubmission, !Ref FeedbackSubmittedSNSTopic, ''] + UserPoolId: !Ref CognitoUserPool + AdminsGroupName: !Join ['', [!Ref 'AWS::StackName', 'AdminsGroup']] + RegisteredGroupName: !Sub '${AWS::StackName}-RegisteredGroup' + DevelopmentMode: !Ref DevelopmentMode # Adds the API as a trigger Events: ProxyApiRoot: @@ -1053,15 +1194,55 @@ Resources: Layers: - !Ref LambdaCommonLayer - CognitoUserPoolsConfirmationStrategyFunction: + CognitoPreSignupTriggerFn: Type: AWS::Serverless::Function Properties: - CodeUri: ../lambdas/cognito-user-pools-confirmation-strategy + FunctionName: !Sub '${AWS::StackName}-CognitoPreSignupTriggerFn' + CodeUri: ../lambdas/cognito-pre-signup-trigger Handler: index.handler MemorySize: 128 - Role: !GetAtt CognitoStrategyLambdaExecutionRole.Arn - Runtime: nodejs8.10 + Role: !GetAtt CognitoPreSignupTriggerExecutionRole.Arn + Runtime: nodejs10.x + Timeout: 3 + Environment: + Variables: + AccountRegistrationMode: !Ref AccountRegistrationMode + Layers: + - !Ref LambdaCommonLayer + + CognitoPostConfirmationTriggerFn: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub '${AWS::StackName}-CognitoPostConfirmationTriggerFn' + CodeUri: ../lambdas/cognito-post-confirmation-trigger + Handler: index.handler + MemorySize: 128 + Role: !GetAtt CognitoPostConfirmationTriggerExecutionRole.Arn + Runtime: nodejs10.x + Timeout: 3 + Environment: + Variables: + AccountRegistrationMode: !Ref AccountRegistrationMode + PreLoginAccountsTableName: !Ref DevPortalPreLoginAccountsTableName + RegisteredGroupName: !Sub '${AWS::StackName}-RegisteredGroup' + Layers: + - !Ref LambdaCommonLayer + + CognitoPostAuthenticationTriggerFn: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub '${AWS::StackName}-CognitoPostAuthenticationTriggerFn' + CodeUri: ../lambdas/cognito-post-authentication-trigger + Handler: index.handler + MemorySize: 128 + Role: !GetAtt CognitoPostAuthenticationTriggerExecutionRole.Arn + Runtime: nodejs10.x Timeout: 3 + Environment: + Variables: + CustomersTableName: !Ref DevPortalCustomersTableName + PreLoginAccountsTableName: !Ref DevPortalPreLoginAccountsTableName + RegisteredGroupName: !Sub '${AWS::StackName}-RegisteredGroup' Layers: - !Ref LambdaCommonLayer @@ -1069,8 +1250,30 @@ Resources: Type: AWS::Cognito::UserPool Properties: UserPoolName: !Ref CognitoIdentityPoolName + # Lambda trigger caveats: + # + # - We can't use the functions' ARNs here, because there would be a + # circular dependency: some functions reference either the UserPool or + # UserPoolGroups within it. + # + # - You must declare an AWS::Lambda::Permission for each lambda here, or + # else calls from Cognito will fail with an AccessDeniedException. See + # `CognitoPreSignupTriggerFnExecutionPermission` as an example. More + # reading: and + # LambdaConfig: - PreSignUp: !GetAtt CognitoUserPoolsConfirmationStrategyFunction.Arn + PreSignUp: !Join + - '' + - - !Sub 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:' + - !Sub '${AWS::StackName}-CognitoPreSignupTriggerFn' + PostConfirmation: !Join + - '' + - - !Sub 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:' + - !Sub '${AWS::StackName}-CognitoPostConfirmationTriggerFn' + PostAuthentication: !Join + - '' + - - !Sub 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:' + - !Sub '${AWS::StackName}-CognitoPostAuthenticationTriggerFn' Policies: PasswordPolicy: MinimumLength: 12 @@ -1254,6 +1457,7 @@ Resources: Roles: authenticated: !GetAtt CognitoAuthenticatedRole.Arn + # Every logged-in Cognito user is "authenticated". CognitoAuthenticatedRole: Type: AWS::IAM::Role Properties: @@ -1271,6 +1475,42 @@ Resources: 'cognito-identity.amazonaws.com:amr': authenticated Policies: - PolicyName: CognitoAuthenticatedRole + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - execute-api:Invoke + Resource: !Join + - '' + - - 'arn:aws:execute-api:' + - !Ref 'AWS::Region' + - ':' + - !Ref 'AWS::AccountId' + - ':' + - !Ref ApiGatewayApi + - /prod/*/signin + Path: '/' + + # A logged-in Cognito user, who is not in a "pending" (invite or request) + # state, is "registered". + CognitoRegisteredRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Federated: cognito-identity.amazonaws.com + Action: sts:AssumeRoleWithWebIdentity + Condition: + StringEquals: + 'cognito-identity.amazonaws.com:aud': !Ref CognitoIdentityPool + 'ForAnyValue:StringLike': + 'cognito-identity.amazonaws.com:amr': authenticated + Policies: + - PolicyName: CognitoRegisteredRole PolicyDocument: Version: '2012-10-17' Statement: @@ -1316,7 +1556,7 @@ Resources: 'ForAnyValue:StringLike': 'cognito-identity.amazonaws.com:amr': authenticated Policies: - - PolicyName: CognitoAuthenticatedRole + - PolicyName: CognitoAdminRole PolicyDocument: Version: '2012-10-17' Statement: @@ -1344,6 +1584,15 @@ Resources: RoleArn: !GetAtt CognitoAdminRole.Arn UserPoolId: !Ref CognitoUserPool + CognitoRegisteredGroup: + Type: AWS::Cognito::UserPoolGroup + Properties: + Description: 'Registered users in the developer portal' + GroupName: !Sub '${AWS::StackName}-RegisteredGroup' + Precedence: 1 + RoleArn: !GetAtt CognitoRegisteredRole.Arn + UserPoolId: !Ref CognitoUserPool + CatalogUpdaterLambdaFunction: Type: AWS::Serverless::Function Properties: diff --git a/dev-portal/src/components/Admin/Accounts/AccountsTableColumns.jsx b/dev-portal/src/components/Admin/Accounts/AccountsTableColumns.jsx index 87ed242a2..22347d902 100644 --- a/dev-portal/src/components/Admin/Accounts/AccountsTableColumns.jsx +++ b/dev-portal/src/components/Admin/Accounts/AccountsTableColumns.jsx @@ -61,22 +61,22 @@ export const ApiKeyId = { export const Promoter = { id: 'Promoter', title: 'Promoter', - render: ({ PromoterIdentityId, PromoterEmailAddress }) => - PromoterIdentityId ? `${PromoterEmailAddress} (${PromoterIdentityId})` : '', + render: ({ PromoterUserId, PromoterEmailAddress }) => + PromoterUserId ? `${PromoterEmailAddress} (${PromoterUserId})` : '', filtering: { - accessor: ({ PromoterIdentityId, PromoterEmailAddress }) => - PromoterIdentityId ? `${PromoterEmailAddress} ${PromoterIdentityId}` : '', + accessor: ({ PromoterUserId, PromoterEmailAddress }) => + PromoterUserId ? `${PromoterEmailAddress} ${PromoterUserId}` : '', }, } export const Inviter = { id: 'Inviter', title: 'Inviter', - render: ({ InviterIdentityId, InviterEmailAddress }) => - InviterIdentityId ? `${InviterEmailAddress} (${InviterIdentityId})` : '', + render: ({ InviterUserId, InviterEmailAddress }) => + InviterUserId ? `${InviterEmailAddress} (${InviterUserId})` : '', filtering: { - accessor: ({ InviterIdentityId, InviterEmailAddress }) => - InviterIdentityId ? `${InviterEmailAddress} ${InviterIdentityId}` : '', + accessor: ({ InviterUserId, InviterEmailAddress }) => + InviterUserId ? `${InviterEmailAddress} ${InviterUserId}` : '', }, } diff --git a/dev-portal/src/components/NavBar.jsx b/dev-portal/src/components/NavBar.jsx index 27e93eb57..349781e01 100755 --- a/dev-portal/src/components/NavBar.jsx +++ b/dev-portal/src/components/NavBar.jsx @@ -8,6 +8,7 @@ import { Menu, Image } from 'semantic-ui-react' import { isAdmin, isAuthenticated, + isRegistered, logout, getLoginRedirectUrl, } from 'services/self' @@ -38,9 +39,11 @@ export const NavBar = observer( Admin Panel )} - - My Dashboard - + {isRegistered() && ( + + My Dashboard + + )} Sign Out diff --git a/dev-portal/src/index.js b/dev-portal/src/index.js index 02a4c2914..9941dc264 100644 --- a/dev-portal/src/index.js +++ b/dev-portal/src/index.js @@ -27,7 +27,7 @@ import NavBar from 'components/NavBar' import Feedback from './components/Feedback' import ApiSearch from './components/ApiSearch' -import { isAdmin, init, login, logout } from 'services/self' +import { isAdmin, isRegistered, init, login, logout } from 'services/self' import './index.css' loadFragments() @@ -37,6 +37,15 @@ loadFragments() // user is not an administrator const feedbackEnabled = window.config.feedbackEnabled +export const RegisteredRoute = ({ component: Component, ...rest }) => ( + + isRegistered() ? : + } + /> +) + export const AdminRoute = ({ component: Component, ...rest }) => ( - + diff --git a/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx b/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx index fe79a6a5a..89d606f99 100644 --- a/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx +++ b/dev-portal/src/pages/Admin/Accounts/PendingInvites.jsx @@ -81,11 +81,27 @@ const PendingInvites = () => { ], ) + const onConfirmResend = useCallback(async () => { + setLoading(true) + try { + await AccountService.resendInviteByEmail(selectedAccount.EmailAddress) + sendMessage(dismiss => ( + + )) + } catch (error) { + sendMessage(dismiss => ( + + )) + } finally { + setLoading(false) + } + }, [sendMessage, selectedAccount]) + const onConfirmDelete = useCallback(async () => { setLoading(true) closeDeleteModal() try { - await AccountService.deleteInviteByIdentityId(selectedAccount.IdentityId) + await AccountService.deleteInviteByUserId(selectedAccount.UserId) sendMessage(dismiss => ( )) @@ -121,6 +137,8 @@ const PendingInvites = () => { @@ -145,6 +163,8 @@ export default PendingInvites const TableActions = ({ canCreate, onClickCreate, + canResend, + onClickResend, canDelete, onClickDelete, }) => ( @@ -154,6 +174,7 @@ const TableActions = ({ disabled={!canCreate} onClick={onClickCreate} /> +