diff --git a/frontend/schema.faker.graphql b/frontend/schema.faker.graphql index d37bf20c0c..8528bae360 100644 --- a/frontend/schema.faker.graphql +++ b/frontend/schema.faker.graphql @@ -735,6 +735,16 @@ type Query { An api endpoint that will send a verification email to a given email address. """ sendValidationEmail(email: EmailAddress!): NotificationEmail + + """ + An api endpoint that will be used to populate a userList component in the front end. + """ + userList(organizaion: Acronym!): UserList + + """ + An api endpoint that will be used to populate a userPage component in the front end. + """ + userPage(userName: EmailAddress!): UserPage queryDmarcReport(reportId: String!): QueryDmarcReport } @@ -1093,6 +1103,73 @@ type WWWScanEdge { cursor: String! } +""" +This custom object is used to populate the userList componenet in the front end. +""" +type UserList { + """ Indicates which organization this list is being queried for.""" + organization: Acronym! + + """Pagination data for this connection.""" + pageInfo: PageInfo! + + """Contains the nodes in this connection. Aka: A list of userItems that will make up the list on the front end.""" + edges: [UserItemEdge]! +} + +type UserListItem implements Node { + """The ID of the object.""" + id: ID! + + """ The users email address or userName """ + userName: EmailAddress! @fake(type: email) + + """ The users display name""" + displayName: String! @fake(type: firstName) + + """ Indicates wether or not this user has enabled two factor authentication""" + tfa: Boolean! @examples(values: [true, false]) + + """ Indicates if this user is an admin of the organization specified in UserList query.""" + admin: Boolean! @examples(values: [true, false]) +} + +"""A Relay edge containing a `UserItem` and its cursor.""" +type UserItemEdge { + """The item at the end of the edge""" + node: UserListItem! + + """A cursor for use in pagination""" + cursor: String! +} + +""" +This custom gql object is used to populate a userPage component in the front end. +""" +type UserPage{ + """ The users email address or userName""" + userName: EmailAddress! @fake(type: email) + + """ The users display name""" + displayName: String! @fake(type: firstName) + + """ Indicates the preferred language of this user.""" + lang: String! @examples(values: ["English, "French]) + + """ Indicates wether or not this user has enabled two factor authentication""" + tfa: Boolean! @examples(values: [true, false]) + + """ Indicates if this user is an admin of the organization specified.""" + userAffiliations: [UserPageAffiliations]! +} + +type UserPageAffiliations{ + """ Indicates if this user is an admin of the organization""" + admin: Boolean! @examples(values: [true, false]) + + """ Indicates which organization this users data is being displayed for.""" + organization: Acronym! @examples(values: ["GC", "ABC", "ASDF", "NSTIR", "BC"]) + """A custom type used to query a DmarcReport for the front end.""" type QueryDmarcReport { diff --git a/frontend/src/UserCard.js b/frontend/src/UserCard.js new file mode 100644 index 0000000000..5eea8f8325 --- /dev/null +++ b/frontend/src/UserCard.js @@ -0,0 +1,58 @@ +import React from 'react' + +import { Badge, Box, Text, PseudoBox } from '@chakra-ui/core' +import { Trans } from '@lingui/macro' + +import { useHistory } from 'react-router-dom' + +import { bool, string } from 'prop-types' + +export function UserCard(props) { + const history = useHistory() + return ( + { + history.push({ + pathname: '/user', + state: { detail: props.userName }, + }) + }} + _hover={{ borderColor: 'gray.200', bg: 'gray.200' }} + p="30px" + > + + + {props.displayName} + + + + + {props.userName} + + + + + TwoFactor + + + Admin + + + + + ) +} + +UserCard.propTypes = { + displayName: string.isRequired, + userName: string.isRequired, + admin: bool.isRequired, + tfa: bool.isRequired, +} diff --git a/frontend/src/UserList.js b/frontend/src/UserList.js index 85cabeabb9..76a4065c46 100644 --- a/frontend/src/UserList.js +++ b/frontend/src/UserList.js @@ -1,81 +1,30 @@ import React from 'react' import { - Badge, Stack, SimpleGrid, Divider, - Box, - Text, Button, Icon, InputGroup, InputLeftElement, Input, - PseudoBox, } from '@chakra-ui/core' import { Trans } from '@lingui/macro' -import gql from 'graphql-tag' +import { QUERY_USERLIST } from './graphql/queries' import { useQuery } from '@apollo/react-hooks' import { PaginationButtons } from './PaginationButtons' +import { UserCard } from './UserCard' export function UserList() { // This function generates the URL when the page loads - const { loading, error, data } = useQuery( - gql` - { - user { - affiliations { - edges { - node { - organization { - acronym - affiliatedUsers { - pageInfo { - hasNextPage - hasPreviousPage - startCursor - endCursor - } - edges { - node { - id - user { - userName - displayName - tfa - affiliations { - edges { - node { - id - organization { - acronym - } - permission - } - } - } - } - } - } - } - } - } - } - } - } - } - `, - ) + const { loading, error, data } = useQuery(QUERY_USERLIST) if (loading) { return

Loading...

} if (error) { - console.log(error) - } - if (data) { - console.log(data) + return

Error :(

} return ( @@ -99,112 +48,46 @@ export function UserList() { - {data - ? data.user.affiliations.edges[0].node.organization.affiliatedUsers.edges.map( - edge => { - return ( - - { - window.alert('clicked box') - }} - _hover={{ borderColor: 'gray.200', bg: 'gray.200' }} - p="30px" - > - - - {edge.node.user.displayName} - - - - - {edge.node.user.userName} - - - - - Orgs:  - {// Populate the user-orgs list. - edge.node.user.affiliations.edges.map( - (edge, i, arr) => { - if (arr.length - 1 === i) { - return ( - - {edge.node.organization.acronym} - - ) - } - return ( - - {edge.node.organization.acronym + ' | '} - - ) - }, - )} - - - - - TwoFactor - - - Admin - - - - - - ) - }, - ) + ? data.userList.edges.map((edge) => { + return ( + + ) + }) : null} - ) } + +/* -- Source code for adding organizations, not being used. -- + + + + Orgs:  + {// Populate the user-orgs list. + edge.node.user.affiliations.edges.map((edge, i, arr) => { + if (arr.length - 1 === i) { + return ( + + {edge.node.organization.acronym} + + ) + } + return ( + + {edge.node.organization.acronym + ' | '} + + ) + })} + +*/ diff --git a/frontend/src/UserPage.js b/frontend/src/UserPage.js index e6e46b91ac..a83b1380ee 100644 --- a/frontend/src/UserPage.js +++ b/frontend/src/UserPage.js @@ -4,7 +4,7 @@ import React from 'react' import { Formik } from 'formik' import { useHistory } from 'react-router-dom' -//import { string } from 'prop-types' +import { string } from 'prop-types' import { Stack, @@ -20,39 +20,13 @@ import { } from '@chakra-ui/core' import { useApolloClient, useMutation, useQuery } from '@apollo/react-hooks' import { PasswordConfirmation } from './PasswordConfirmation' -import gql from 'graphql-tag' - -export function UserPage() { - const userName = 'mike@korora.ca' - - // TODO: Move to mutations folder - const UPDATE_PASSWORD = gql` - mutation UpdatePassword( - $userName: EmailAddress! - $password: String! - $confirmPassword: String! - ) { - updatePassword( - userName: $userName - password: $password - confirmPassword: $confirmPassword - ) { - user { - userName - } - } - } - ` - - const QUERY_USER = gql` - query User($userName: EmailAddress!) { - user(userName: $userName) { - displayName - lang - } - } - ` +import { useLocation } from 'react-router-dom' +import { QUERY_USER } from './graphql/queries' +import { UPDATE_PASSWORD } from './graphql/mutations' + +export function UserPage(props) { + const location = useLocation() const client = useApolloClient() const toast = useToast() const history = useHistory() @@ -71,24 +45,34 @@ export function UserPage() { error: queryUserError, data: queryUserData, } = useQuery(QUERY_USER, { - variables: { userName: userName }, + variables: { + userName: location.state ? location.state.detail : props.userName, + }, }) - if (updatePasswordLoading || queryUserLoading) { + if (queryUserLoading) { + return

Loading user...

+ } + + if (queryUserError) { + return

{String(queryUserError)}

+ } + + if (updatePasswordLoading) { return

Loading...

} - if (queryUserError || updatePasswordError) { - return

Error

+ if (updatePasswordError) { + return

{String(updatePasswordError)}

} return ( { window.alert('coming soon!!\n' + JSON.stringify(values, null, 2)) @@ -153,7 +137,7 @@ export function UserPage() { Administrative Account Account Active @@ -166,6 +150,7 @@ export function UserPage() { onClick={() => { history.push('/two-factor-code') }} + isDisabled={location.state ? true : false} > Enable 2FA @@ -179,6 +164,7 @@ export function UserPage() { Manage API keys + @@ -202,53 +189,60 @@ export function UserPage() { Change Password - - Change your password below by entering and confirming a new password. - - - { - // Submit GraphQL mutation - console.log(values) - await updatePassword({ - variables: { - userName: 'testuser@test.ca', // This needs to be retreived from a seperate GQL query or props that will populate this entire page with data. - password: values.password, - confirmPassword: values.confirmPassword, - }, - }) - if (!updatePasswordError) { - console.log(updatePasswordData) - toast({ - title: 'Password Updated.', - description: 'You have successfully changed your password.', - status: 'success', - duration: 9000, - isClosable: true, + {location.state ? ( + You can only change the password for your own account. + ) : ( + { + // Submit GraphQL mutation + console.log(values) + await updatePassword({ + variables: { + userName: 'testuser@test.ca', // This needs to be retreived from a seperate GQL query or props that will populate this entire page with data. + password: values.password, + confirmPassword: values.confirmPassword, + }, }) - } - }} - > - {({ handleSubmit, isSubmitting }) => ( -
- - - - - - - )} -
+ + if (!updatePasswordError) { + console.log(updatePasswordData) + toast({ + title: 'Password Updated.', + description: 'You have successfully changed your password.', + status: 'success', + duration: 9000, + isClosable: true, + }) + } + }} + > + {({ handleSubmit, isSubmitting }) => ( +
+ + Change your password below by entering and confirming a new + password. + + + + + + + + )} +
+ )}
) } + +UserPage.propTypes = { userName: string } diff --git a/frontend/src/__tests__/UserCard.test.js b/frontend/src/__tests__/UserCard.test.js new file mode 100644 index 0000000000..5b7ec9385e --- /dev/null +++ b/frontend/src/__tests__/UserCard.test.js @@ -0,0 +1,91 @@ +import React from 'react' +import { i18n } from '@lingui/core' +import { render } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { ThemeProvider, theme } from '@chakra-ui/core' +import { I18nProvider } from '@lingui/react' +import { MockedProvider } from '@apollo/react-testing' + +import { UserCard } from '../UserCard' + +// If this unused import, the mocked data test fails. VERY weird. +import App from '../App' + +describe('', () => { + it('successfully renders', async () => { + const { container } = render( + + + + + + + + + , + ) + expect(container).toBeDefined() + }) + it('badges are green when TwoFactor and Admin values are true', async () => { + const { container, getByText } = render( + + + + + + + + + , + ) + expect(container).toBeDefined() + + const tfaBadge = getByText(/TwoFactor/i) + const adminBadge = getByText(/Admin/i) + + expect(tfaBadge).toBeDefined() + expect(adminBadge).toBeDefined() + + expect(tfaBadge).toHaveStyle('background-color: rgb(198, 246, 213)') + expect(adminBadge).toHaveStyle('background-color: rgb(198, 246, 213)') + }) + + it('badges are red when TwoFactor and Admin values are false', async () => { + const { container, getByText } = render( + + + + + + + + + , + ) + expect(container).toBeDefined() + + const tfaBadge = getByText(/TwoFactor/i) + const adminBadge = getByText(/Admin/i) + + expect(tfaBadge).toBeDefined() + expect(adminBadge).toBeDefined() + + expect(tfaBadge).toHaveStyle('background-color: rgb(254, 215, 215)') + expect(adminBadge).toHaveStyle('background-color: rgb(254, 215, 215)') + }) +}) diff --git a/frontend/src/__tests__/UserList.test.js b/frontend/src/__tests__/UserList.test.js index 4479e56d65..2da4791912 100644 --- a/frontend/src/__tests__/UserList.test.js +++ b/frontend/src/__tests__/UserList.test.js @@ -1,16 +1,25 @@ import React from 'react' import { UserList } from '../UserList' import { i18n } from '@lingui/core' -import { render, cleanup } from '@testing-library/react' -import { MemoryRouter } from 'react-router-dom' +import { + render, + waitForElementToBeRemoved, + fireEvent, + waitFor, +} from '@testing-library/react' +import { MemoryRouter, Router } from 'react-router-dom' import { ThemeProvider, theme } from '@chakra-ui/core' import { I18nProvider } from '@lingui/react' import { MockedProvider } from '@apollo/react-testing' +import { createMemoryHistory } from 'history' -describe('', () => { - afterEach(cleanup) +import { QUERY_USERLIST } from '../graphql/queries' + +// If this unused import, the mocked data test fails. VERY weird. +import App from '../App' - it('the component renders', async () => { +describe('', () => { + it('successfully renders', async () => { const { container } = render( @@ -22,6 +31,132 @@ describe('', () => { , ) - expect(container).toBeTruthy() + expect(container).toBeDefined() + }) + + it('successfully renders with mocked data', async () => { + const mocks = [ + { + request: { + query: QUERY_USERLIST, + }, + result: { + data: { + userList: { + organization: 'TEST', + pageInfo: { + hasNextPage: true, + hasPreviousPage: true, + }, + edges: [ + { + node: { + id: 'ODY0MDEzMTE1NA==', + userName: 'testuser@testemail.gc.ca', + admin: false, + tfa: false, + displayName: 'Test User', + }, + }, + ], + }, + }, + }, + }, + ] + + // Set the inital history item to user-list + const { container, getAllByText, getByText } = render( + + + + + + + + + , + ) + expect(container).toBeDefined() + + expect(getByText('Loading...')).toBeInTheDocument() + const loadingElement = getByText('Loading...') + + await waitForElementToBeRemoved(loadingElement) + + // Get all of the mocked user cards, and expect there to be only one entry. + const userCards = getAllByText('testuser@testemail.gc.ca') + expect(userCards).toHaveLength(1) + }) + + it('redirects to userPage when a list element is clicked', async () => { + const mocks = [ + { + request: { + query: QUERY_USERLIST, + }, + result: { + data: { + userList: { + organization: 'TEST', + pageInfo: { + hasNextPage: true, + hasPreviousPage: true, + }, + edges: [ + { + node: { + id: 'ODY0MDEzMTE1NA==', + userName: 'testuser@testemail.gc.ca', + admin: false, + tfa: false, + displayName: 'Test User', + }, + }, + ], + }, + }, + }, + }, + ] + + // create a history object and inject it so we can inspect it afterwards + // for the side effects of our form submission (a redirect to /!). + const history = createMemoryHistory({ + initialEntries: ['user-list'], + initialIndex: 0, + }) + + // Set the inital history item to user-list + const { container, getAllByText, getByText } = render( + + + + + + + + + , + ) + expect(container).toBeDefined() + + expect(getByText('Loading...')).toBeInTheDocument() + const loadingElement = getByText('Loading...') + + await waitForElementToBeRemoved(loadingElement) + + // Get all of the mocked user cards, and expect there to be only one entry. + const userCards = getAllByText('testuser@testemail.gc.ca') + expect(userCards).toHaveLength(1) + + const leftClick = { button: 0 } + fireEvent.click(userCards[0], leftClick) + // default `button` property for click events is set to `0` which is a left click. + + await waitFor(() => { + // Path should be '/user', so expect that value + expect(history.location.pathname).toEqual('/user') + }) }) }) diff --git a/frontend/src/__tests__/UserPage.test.js b/frontend/src/__tests__/UserPage.test.js new file mode 100644 index 0000000000..0068242b51 --- /dev/null +++ b/frontend/src/__tests__/UserPage.test.js @@ -0,0 +1,69 @@ +import React from 'react' +import { UserPage } from '../UserPage' +import { i18n } from '@lingui/core' +import { render, cleanup, act } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { ThemeProvider, theme } from '@chakra-ui/core' +import { I18nProvider } from '@lingui/react' +import { MockedProvider } from '@apollo/react-testing' + +import { QUERY_USER } from '../graphql/queries' +import { UPDATE_PASSWORD } from '../graphql/mutations' + +describe('', () => { + afterEach(cleanup) + + const values = { + userName: 'testuser@testemail.gc.ca', + } + + const mocks = [ + { + request: { + query: QUERY_USER, + variables: { + userName: values.userName, + }, + }, + result: { + user: { + userName: 'testuser@testemail.gc.ca', + displayName: 'Test User', + lang: 'English', + userAffiliations: { + admin: true, + organization: 'TEST', + }, + }, + }, + }, + { + request: { + query: UPDATE_PASSWORD, + }, + result: { + updatePassword: { + user: { + userName: 'Gregg_Grady4@hotmail.com', + }, + }, + }, + }, + ] + + it('renders without error', () => { + act(() => { + render( + + + + + + + + + , + ) + }) + }) +}) diff --git a/frontend/src/graphql/mutations.js b/frontend/src/graphql/mutations.js index 698dcd8ce5..04da8bec60 100644 --- a/frontend/src/graphql/mutations.js +++ b/frontend/src/graphql/mutations.js @@ -42,4 +42,22 @@ export const VALIDATE_TWO_FACTOR = gql` } ` +export const UPDATE_PASSWORD = gql` + mutation UpdatePassword( + $userName: EmailAddress! + $password: String! + $confirmPassword: String! + ) { + updatePassword( + userName: $userName + password: $password + confirmPassword: $confirmPassword + ) { + user { + userName + } + } + } +` + export default '' diff --git a/frontend/src/graphql/queries.js b/frontend/src/graphql/queries.js index 203a082573..facd7ea745 100644 --- a/frontend/src/graphql/queries.js +++ b/frontend/src/graphql/queries.js @@ -18,6 +18,43 @@ export const GENERATE_OTP_URL = gql` } ` +export const QUERY_USERLIST = gql` + { + userList(organizaion: "NS") { + organization + pageInfo { + hasNextPage + hasPreviousPage + } + edges { + node { + id + userName + admin + tfa + displayName + } + } + } + } +` + +export const QUERY_USER = gql` + query UserPage($userName: EmailAddress!) { + userPage(userName: $userName) { + userName + tfa + lang + displayName + userAffiliations{ + admin + organization + } + } + } +` + + export const QUERY_DMARC_REPORT = gql` query QueryDmarcReport($reportId: String!) { queryDmarcReport(reportId: $reportId) { diff --git a/platform/overlays/gke/kustomization.yaml b/platform/overlays/gke/kustomization.yaml index 1883da27bc..5edd8475b6 100644 --- a/platform/overlays/gke/kustomization.yaml +++ b/platform/overlays/gke/kustomization.yaml @@ -10,7 +10,7 @@ images: - name: gcr.io/track-compliance/api newTag: master-4301b10 - name: gcr.io/track-compliance/frontend - newTag: master-7ee5ba8 + newTag: master-91c4299 patchesStrategicMerge: - tracker-api-deployment.yaml - tracker-frontend-deployment.yaml