diff --git a/api/src/initialize-loaders.js b/api/src/initialize-loaders.js index 4bd5158223..3bda412cb7 100644 --- a/api/src/initialize-loaders.js +++ b/api/src/initialize-loaders.js @@ -51,6 +51,7 @@ import { loadOrgConnectionsByDomainId, loadOrgConnectionsByUserId, loadWebCheckConnectionsByUserId, + loadAllOrganizationDomainStatuses, loadOrganizationDomainStatuses, } from './organization/loaders' import { @@ -312,6 +313,13 @@ export function initializeLoaders({ language, i18n, }), + loadAllOrganizationDomainStatuses: loadAllOrganizationDomainStatuses({ + query, + userKey, + cleanseInput, + language, + i18n, + }), loadOrganizationDomainStatuses: loadOrganizationDomainStatuses({ query, userKey, diff --git a/api/src/organization/loaders/index.js b/api/src/organization/loaders/index.js index 84e502655c..c539610d5e 100644 --- a/api/src/organization/loaders/index.js +++ b/api/src/organization/loaders/index.js @@ -3,4 +3,5 @@ export * from './load-organization-by-slug' export * from './load-organization-connections-by-domain-id' export * from './load-organization-connections-by-user-id' export * from './load-web-check-connections-by-user-id' +export * from './load-all-organization-domain-statuses' export * from './load-organization-domain-statuses' diff --git a/api/src/organization/loaders/load-all-organization-domain-statuses.js b/api/src/organization/loaders/load-all-organization-domain-statuses.js new file mode 100644 index 0000000000..b5d247d7da --- /dev/null +++ b/api/src/organization/loaders/load-all-organization-domain-statuses.js @@ -0,0 +1,44 @@ +import { t } from '@lingui/macro' + +export const loadAllOrganizationDomainStatuses = + ({ query, userKey, i18n }) => + + async () => { + let statuses + + try { + statuses = ( + await query` + WITH domains + FOR org IN organizations + FILTER org.orgDetails.en.acronym != "SA" + FOR domain, claim IN 1..1 OUTBOUND org._id claims + RETURN { + "Organization name (English)": org.orgDetails.en.name, + "Nom de l'organisation (Français)": org.orgDetails.fr.name, + "Domain": domain.domain, + "ITPIN": [domain.status.https,domain.status.hsts,domain.status.ciphers,domain.status.curves,domain.status.protocols] ANY == "fail" ? "fail" : "pass", + "HTTPS": domain.status.https, + "HSTS": domain.status.hsts, + "Ciphers": domain.status.ciphers, + "Curves": domain.status.curves, + "Protocols": domain.status.protocols, + "SPF": domain.status.spf, + "DKIM": domain.status.dkim, + "DMARC": domain.status.dmarc + } + ` + ).all() + } catch (err) { + console.error( + `Database error occurred when user: ${userKey} running loadAllOrganizationDomainStatuses: ${err}`, + ) + throw new Error( + i18n._( + t`Unable to load all organization domain statuses. Please try again.`, + ), + ) + } + + return statuses + } diff --git a/api/src/organization/queries/__tests__/get-all-organization-domain-statuses.test.js b/api/src/organization/queries/__tests__/get-all-organization-domain-statuses.test.js new file mode 100644 index 0000000000..b82c313500 --- /dev/null +++ b/api/src/organization/queries/__tests__/get-all-organization-domain-statuses.test.js @@ -0,0 +1,439 @@ +import { ensure, dbNameFromFile } from 'arango-tools' +import { graphql, GraphQLSchema, GraphQLError } from 'graphql' + +import { createQuerySchema } from '../../../query' +import { createMutationSchema } from '../../../mutation' +import { checkSuperAdmin, userRequired, verifiedRequired } from '../../../auth' +import { loadUserByKey } from '../../../user/loaders' +import { + loadAllOrganizationDomainStatuses, +} from '../../loaders' +import dbschema from '../../../../database.json' +import { setupI18n } from '@lingui/core' +import englishMessages from '../../../locale/en/messages' +import frenchMessages from '../../../locale/fr/messages' + +const { DB_PASS: rootPass, DB_URL: url } = process.env + +describe('given getAllOrganizationDomainStatuses', () => { + let query, + drop, + truncate, + schema, + collections, + orgOne, + orgTwo, + superAdminOrg, + domainOne, + domainTwo, + i18n, + user + + const consoleOutput = [] + const mockedInfo = (output) => consoleOutput.push(output) + const mockedWarn = (output) => consoleOutput.push(output) + const mockedError = (output) => consoleOutput.push(output) + beforeAll(async () => { + // Create GQL Schema + schema = new GraphQLSchema({ + query: createQuerySchema(), + mutation: createMutationSchema(), + }) + }) + beforeAll(() => { + i18n = setupI18n({ + locale: 'en', + localeData: { + en: { plurals: {} }, + fr: { plurals: {} }, + }, + locales: ['en', 'fr'], + messages: { + en: englishMessages.messages, + fr: frenchMessages.messages, + }, + }) + }) + beforeAll(async () => { + // Generate DB Items + ;({ query, drop, truncate, collections } = await ensure({ + variables: { + dbname: dbNameFromFile(__filename), + username: 'root', + rootPassword: rootPass, + password: rootPass, + url, + }, + + schema: dbschema, + })) + }) + beforeEach(async () => { + console.info = mockedInfo + console.warn = mockedWarn + console.error = mockedError + consoleOutput.length = 0 + }) + beforeEach(async () => { + user = await collections.users.save({ + displayName: 'Test Account', + userName: 'test.account@istio.actually.exists', + preferredLang: 'english', + emailValidated: true, + }) + superAdminOrg = await collections.organizations.save({ + orgDetails: { + en: { + slug: 'super-admin', + acronym: 'SA', + name: 'Super Admin', + zone: 'NFED', + sector: 'NTBS', + country: 'Canada', + province: 'Ontario', + city: 'Ottawa', + }, + fr: { + slug: 'super-admin', + acronym: 'SA', + name: 'Super Admin', + zone: 'NPFED', + sector: 'NPTBS', + country: 'Canada', + province: 'Ontario', + city: 'Ottawa', + }, + }, + }) + orgOne = await collections.organizations.save({ + orgDetails: { + en: { + slug: 'definitely-treasury-board-secretariat', + acronym: 'NTBS', + name: 'Definitely Treasury Board of Canada Secretariat', + zone: 'NFED', + sector: 'NTBS', + country: 'Canada', + province: 'Ontario', + city: 'Ottawa', + }, + fr: { + slug: 'definitivement-secretariat-conseil-tresor', + acronym: 'NPSCT', + name: 'Définitivement Secrétariat du Conseil du Trésor du Canada', + zone: 'NPFED', + sector: 'NPTBS', + country: 'Canada', + province: 'Ontario', + city: 'Ottawa', + }, + }, + }) + orgTwo = await collections.organizations.save({ + orgDetails: { + en: { + slug: 'not-treasury-board-secretariat', + acronym: 'NTBS', + name: 'Not Treasury Board of Canada Secretariat', + zone: 'NFED', + sector: 'NTBS', + country: 'Canada', + province: 'Ontario', + city: 'Ottawa', + }, + fr: { + slug: 'ne-pas-secretariat-conseil-tresor', + acronym: 'NPSCT', + name: 'Ne Pas Secrétariat du Conseil Trésor du Canada', + zone: 'NPFED', + sector: 'NPTBS', + country: 'Canada', + province: 'Ontario', + city: 'Ottawa', + }, + }, + }) + domainOne = await collections.domains.save({ + domain: 'domain.one', + status: { + https: 'fail', + hsts: 'pass', + ciphers: 'pass', + curves: 'pass', + protocols: 'pass', + spf: 'pass', + dkim: 'pass', + dmarc: 'pass', + }, + }) + domainTwo = await collections.domains.save({ + domain: 'domain.two', + status: { + https: 'pass', + hsts: 'fail', + ciphers: 'fail', + curves: 'pass', + protocols: 'fail', + spf: 'pass', + dkim: 'pass', + dmarc: 'fail', + }, + }) + await collections.claims.save({ + _from: orgOne._id, + _to: domainOne._id, + }) + await collections.claims.save({ + _from: orgTwo._id, + _to: domainTwo._id, + }) + }) + afterEach(async () => { + await truncate() + }) + afterAll(async () => { + await drop() + }) + let loginRequiredBool + describe('login is not required', () => { + beforeEach(async () => { + loginRequiredBool = false + }) + describe('the user is not a super admin', () => { + it('returns all domain status results', async () => { + const response = await graphql( + schema, + ` + query { + getAllOrganizationDomainStatuses + } + `, + null, + { + i18n, + userKey: user._key, + auth: { + checkSuperAdmin: checkSuperAdmin({ + i18n, + userKey: user._key, + query, + }), + userRequired: userRequired({ + i18n, + userKey: user._key, + loadUserByKey: loadUserByKey({ + query, + userKey: user._key, + i18n, + }), + }), + verifiedRequired: verifiedRequired({}), + loginRequiredBool: loginRequiredBool, + }, + loaders: { + loadAllOrganizationDomainStatuses: + loadAllOrganizationDomainStatuses({ + query, + userKey: user._key, + i18n, + }), + }, + }, + ) + const expectedResponse = { + data: { + getAllOrganizationDomainStatuses: `Organization name (English),Nom de l'organisation (Français),Domain,ITPIN,HTTPS,HSTS,Ciphers,Curves,Protocols,SPF,DKIM,DMARC +Definitely Treasury Board of Canada Secretariat,Définitivement Secrétariat du Conseil du Trésor du Canada,domain.one,fail,fail,pass,pass,pass,pass,pass,pass,pass +Not Treasury Board of Canada Secretariat,Ne Pas Secrétariat du Conseil Trésor du Canada,domain.two,fail,pass,fail,fail,pass,fail,pass,pass,fail`, + }, + } + expect(response).toEqual(expectedResponse) + expect(consoleOutput).toEqual([ + `User ${user._key} successfully retrieved all domain statuses.`, + ]) + }) + }) + describe('the user is a super admin', () => { + beforeEach(async () => { + await collections.affiliations.save({ + _from: superAdminOrg._id, + _to: user._id, + permission: 'super_admin', + }) + }) + + it('returns all domain status results', async () => { + const response = await graphql( + schema, + ` + query { + getAllOrganizationDomainStatuses + } + `, + null, + { + i18n, + userKey: user._key, + auth: { + checkSuperAdmin: checkSuperAdmin({ + i18n, + userKey: user._key, + query, + }), + userRequired: userRequired({ + i18n, + userKey: user._key, + loadUserByKey: loadUserByKey({ + query, + userKey: user._key, + i18n, + }), + }), + verifiedRequired: verifiedRequired({}), + loginRequiredBool: loginRequiredBool, + }, + loaders: { + loadAllOrganizationDomainStatuses: + loadAllOrganizationDomainStatuses({ + query, + userKey: user._key, + i18n, + }), + }, + }, + ) + const expectedResponse = { + data: { + getAllOrganizationDomainStatuses: `Organization name (English),Nom de l'organisation (Français),Domain,ITPIN,HTTPS,HSTS,Ciphers,Curves,Protocols,SPF,DKIM,DMARC +Definitely Treasury Board of Canada Secretariat,Définitivement Secrétariat du Conseil du Trésor du Canada,domain.one,fail,fail,pass,pass,pass,pass,pass,pass,pass +Not Treasury Board of Canada Secretariat,Ne Pas Secrétariat du Conseil Trésor du Canada,domain.two,fail,pass,fail,fail,pass,fail,pass,pass,fail`, + }, + } + expect(response).toEqual(expectedResponse) + expect(consoleOutput).toEqual([ + `User ${user._key} successfully retrieved all domain statuses.`, + ]) + }) + }) + }) + describe('login is required', () => { + beforeEach(async () => { + loginRequiredBool = true + }) + describe('the user is not a super admin', () => { + it('returns a permission error', async () => { + const response = await graphql( + schema, + ` + query { + getAllOrganizationDomainStatuses + } + `, + null, + { + i18n, + userKey: user._key, + auth: { + checkSuperAdmin: checkSuperAdmin({ + i18n, + userKey: user._key, + query, + }), + userRequired: userRequired({ + i18n, + userKey: user._key, + loadUserByKey: loadUserByKey({ + query, + userKey: user._key, + i18n, + }), + }), + verifiedRequired: verifiedRequired({}), + loginRequiredBool: loginRequiredBool, + }, + loaders: { + loadAllOrganizationDomainStatuses: + loadAllOrganizationDomainStatuses({ + query, + userKey: user._key, + i18n, + }), + }, + }, + ) + const error = [ + new GraphQLError( + "Permissions error. You do not have sufficient permissions to access this data.", + ), + ] + + expect(response.errors).toEqual(error) + expect(consoleOutput).toEqual([ + `User: ${user._key} attempted to load all organization statuses but login is required and they are not a super admin.`, + ]) + }) + }) + describe('the user is a super admin', () => { + beforeEach(async () => { + await collections.affiliations.save({ + _from: superAdminOrg._id, + _to: user._id, + permission: 'super_admin', + }) + }) + + it('returns all domain status results', async () => { + const response = await graphql( + schema, + ` + query { + getAllOrganizationDomainStatuses + } + `, + null, + { + i18n, + userKey: user._key, + auth: { + checkSuperAdmin: checkSuperAdmin({ + i18n, + userKey: user._key, + query, + }), + userRequired: userRequired({ + i18n, + userKey: user._key, + loadUserByKey: loadUserByKey({ + query, + userKey: user._key, + i18n, + }), + }), + verifiedRequired: verifiedRequired({}), + loginRequiredBool: loginRequiredBool, + }, + loaders: { + loadAllOrganizationDomainStatuses: + loadAllOrganizationDomainStatuses({ + query, + userKey: user._key, + i18n, + }), + }, + }, + ) + const expectedResponse = { + data: { + getAllOrganizationDomainStatuses: `Organization name (English),Nom de l'organisation (Français),Domain,ITPIN,HTTPS,HSTS,Ciphers,Curves,Protocols,SPF,DKIM,DMARC +Definitely Treasury Board of Canada Secretariat,Définitivement Secrétariat du Conseil du Trésor du Canada,domain.one,fail,fail,pass,pass,pass,pass,pass,pass,pass +Not Treasury Board of Canada Secretariat,Ne Pas Secrétariat du Conseil Trésor du Canada,domain.two,fail,pass,fail,fail,pass,fail,pass,pass,fail`, + }, + } + expect(response).toEqual(expectedResponse) + expect(consoleOutput).toEqual([ + `User ${user._key} successfully retrieved all domain statuses.`, + ]) + }) + }) + }) +}) diff --git a/api/src/organization/queries/get-all-organization-domain-statuses.js b/api/src/organization/queries/get-all-organization-domain-statuses.js new file mode 100644 index 0000000000..028802ba9d --- /dev/null +++ b/api/src/organization/queries/get-all-organization-domain-statuses.js @@ -0,0 +1,57 @@ +import {GraphQLString} from 'graphql' + +import {t} from "@lingui/macro" + +export const getAllOrganizationDomainStatuses = { + type: GraphQLString, + description: 'CSV formatted output of all domains in all organizations including their email and web scan statuses.', + resolve: async ( + _, + _args, + { + userKey, + i18n, + auth: { + checkSuperAdmin, + userRequired, + verifiedRequired, + loginRequiredBool, + }, + loaders: {loadAllOrganizationDomainStatuses}, + }, + ) => { + if (loginRequiredBool) { + const user = await userRequired() + verifiedRequired({user}) + + const isSuperAdmin = await checkSuperAdmin() + + if (!isSuperAdmin) { + console.warn( + `User: ${userKey} attempted to load all organization statuses but login is required and they are not a super admin.`, + ) + throw new Error( + i18n._( + t`Permissions error. You do not have sufficient permissions to access this data.`, + ), + ) + } + + } + + const domainStatuses = await loadAllOrganizationDomainStatuses() + + console.info(`User ${userKey} successfully retrieved all domain statuses.`) + + if(domainStatuses === undefined) return domainStatuses + + const headers = ["Organization name (English)", "Nom de l'organisation (Français)", "Domain", "ITPIN", "HTTPS", "HSTS", "Ciphers", "Curves", "Protocols", "SPF", "DKIM", "DMARC"] + let csvOutput = headers.join(',') + domainStatuses.forEach((domainStatus) => { + const csvLine = headers.map((header) => domainStatus[header]).join(',') + csvOutput += `\n${csvLine}` + }) + + return csvOutput + }, +} diff --git a/api/src/organization/queries/index.js b/api/src/organization/queries/index.js index 7b1d16f8a5..d4c479399c 100644 --- a/api/src/organization/queries/index.js +++ b/api/src/organization/queries/index.js @@ -1,3 +1,4 @@ export * from './find-my-organizations' export * from './find-my-web-check-organizations' export * from './find-organization-by-slug' +export * from './get-all-organization-domain-statuses' diff --git a/frontend/mocking/faked_schema.js b/frontend/mocking/faked_schema.js index 4702c99c51..3cc8427e84 100644 --- a/frontend/mocking/faked_schema.js +++ b/frontend/mocking/faked_schema.js @@ -106,9 +106,6 @@ export const getTypeNames = () => gql` # String argument used to search for organizations. search: String - # Filter orgs based off of the user being an admin of them. - isAdmin: Boolean - # Returns the items in the list that come after the specified cursor. after: String @@ -128,6 +125,9 @@ export const getTypeNames = () => gql` orgSlug: Slug! ): Organization + # CSV formatted output of all domains in all organizations including their email and web scan statuses. + getAllOrganizationDomainStatuses: String + # Email summary computed values, used to build summary cards. mailSummary: CategorizedSummary @@ -1070,9 +1070,9 @@ export const getTypeNames = () => gql` # Returns the last n items from the list. last: Int ): GuidanceTagConnection - @deprecated( - reason: "This has been sub-divided into neutral, negative, and positive tags." - ) + @deprecated( + reason: "This has been sub-divided into neutral, negative, and positive tags." + ) # Negative guidance tags found during scan. negativeGuidanceTags( @@ -1310,9 +1310,9 @@ export const getTypeNames = () => gql` # Returns the last n items from the list. last: Int ): GuidanceTagConnection - @deprecated( - reason: "This has been sub-divided into neutral, negative, and positive tags." - ) + @deprecated( + reason: "This has been sub-divided into neutral, negative, and positive tags." + ) # Negative guidance tags found during DMARC Scan. negativeGuidanceTags( @@ -1463,9 +1463,9 @@ export const getTypeNames = () => gql` # Returns the last n items from the list. last: Int ): GuidanceTagConnection - @deprecated( - reason: "This has been sub-divided into neutral, negative, and positive tags." - ) + @deprecated( + reason: "This has been sub-divided into neutral, negative, and positive tags." + ) # Negative guidance tags found during scan. negativeGuidanceTags( @@ -1667,9 +1667,9 @@ export const getTypeNames = () => gql` # Returns the last n items from the list. last: Int ): GuidanceTagConnection - @deprecated( - reason: "This has been sub-divided into neutral, negative, and positive tags." - ) + @deprecated( + reason: "This has been sub-divided into neutral, negative, and positive tags." + ) # Negative guidance tags found during scan. negativeGuidanceTags( @@ -1835,9 +1835,9 @@ export const getTypeNames = () => gql` # Returns the last n items from the list. last: Int ): GuidanceTagConnection - @deprecated( - reason: "This has been sub-divided into neutral, negative, and positive tags." - ) + @deprecated( + reason: "This has been sub-divided into neutral, negative, and positive tags." + ) # Negative guidance tags found during scan. negativeGuidanceTags( @@ -2123,9 +2123,9 @@ export const getTypeNames = () => gql` # Guidance for any issues that were found from the report. guidance: String - @deprecated( - reason: "This has been turned into the \`guidanceTag\` field providing detailed information to act upon if a given tag is present." - ) + @deprecated( + reason: "This has been turned into the \`guidanceTag\` field providing detailed information to act upon if a given tag is present." + ) # Guidance for any issues that were found from the report. guidanceTag: GuidanceTag @@ -2279,9 +2279,9 @@ export const getTypeNames = () => gql` # Guidance for any issues that were found from the report. guidance: String - @deprecated( - reason: "This has been turned into the \`guidanceTag\` field providing detailed information to act upon if a given tag is present." - ) + @deprecated( + reason: "This has been turned into the \`guidanceTag\` field providing detailed information to act upon if a given tag is present." + ) # Guidance for any issues that were found from the report. guidanceTag: GuidanceTag @@ -2384,13 +2384,16 @@ export const getTypeNames = () => gql` # Whether the organization is a verified organization. verified: Boolean - # Whether or not the domain has a aggregate dmarc report. + # List of tags assigned to domains within the organization. tags: TagConnection domains: WebCheckDomainConnection } type TagConnection { + # List of tags assigned to the domain. edges: [DomainTag] + + # Total number of tags assigned to domain. totalCount: Int } @@ -2783,24 +2786,16 @@ export const getTypeNames = () => gql` updateDomain(input: UpdateDomainInput!): UpdateDomainPayload # This mutation allows the creation of an organization inside the database. - createOrganization( - input: CreateOrganizationInput! - ): CreateOrganizationPayload + createOrganization(input: CreateOrganizationInput!): CreateOrganizationPayload # This mutation allows the removal of unused organizations. - removeOrganization( - input: RemoveOrganizationInput! - ): RemoveOrganizationPayload + removeOrganization(input: RemoveOrganizationInput!): RemoveOrganizationPayload # Mutation allows the modification of organizations if any changes to the organization may occur. - updateOrganization( - input: UpdateOrganizationInput! - ): UpdateOrganizationPayload + updateOrganization(input: UpdateOrganizationInput!): UpdateOrganizationPayload # Mutation allows the verification of an organization. - verifyOrganization( - input: VerifyOrganizationInput! - ): VerifyOrganizationPayload + verifyOrganization(input: VerifyOrganizationInput!): VerifyOrganizationPayload # This mutation allows users to give their credentials and retrieve a token that gives them access to restricted content. authenticate(input: AuthenticateInput!): AuthenticatePayload @@ -2840,9 +2835,7 @@ export const getTypeNames = () => gql` signUp(input: SignUpInput!): SignUpPayload # This mutation allows the user to update their account password. - updateUserPassword( - input: UpdateUserPasswordInput! - ): UpdateUserPasswordPayload + updateUserPassword(input: UpdateUserPasswordInput!): UpdateUserPasswordPayload # This mutation allows the user to update their user profile to change various details of their current profile. updateUserProfile(input: UpdateUserProfileInput!): UpdateUserProfilePayload @@ -2949,9 +2942,7 @@ export const getTypeNames = () => gql` # This union is used with the \`transferOrgOwnership\` mutation, allowing for # users to transfer ownership of a given organization, and support any errors that may occur. - union TransferOrgOwnershipUnion = - AffiliationError - | TransferOrgOwnershipResult + union TransferOrgOwnershipUnion = AffiliationError | TransferOrgOwnershipResult # This object is used to inform the user that they successful transferred ownership of a given organization. type TransferOrgOwnershipResult { @@ -3348,9 +3339,7 @@ export const getTypeNames = () => gql` } # This union is used with the \`RemovePhoneNumber\` mutation, allowing for users to remove their phone number, and support any errors that may occur - union RemovePhoneNumberUnion = - RemovePhoneNumberError - | RemovePhoneNumberResult + union RemovePhoneNumberUnion = RemovePhoneNumberError | RemovePhoneNumberResult # This object is used to inform the user if any errors occurred while removing their phone number. type RemovePhoneNumberError { @@ -3563,7 +3552,7 @@ export const getTypeNames = () => gql` # This union is used with the \`updateUserPassword\` mutation, allowing for users to update their password, and support any errors that may occur union UpdateUserPasswordUnion = - UpdateUserPasswordError + UpdateUserPasswordError | UpdateUserPasswordResultType # This object is used to inform the user if any errors occurred while updating their password. @@ -3600,9 +3589,7 @@ export const getTypeNames = () => gql` } # This union is used with the \`updateUserProfile\` mutation, allowing for users to update their profile, and support any errors that may occur - union UpdateUserProfileUnion = - UpdateUserProfileError - | UpdateUserProfileResult + union UpdateUserProfileUnion = UpdateUserProfileError | UpdateUserProfileResult # This object is used to inform the user if any errors occurred while updating their profile. type UpdateUserProfileError { @@ -3674,9 +3661,7 @@ export const getTypeNames = () => gql` } # This union is used with the \`verifyPhoneNumber\` mutation, allowing for users to verify their phone number, and support any errors that may occur - union VerifyPhoneNumberUnion = - VerifyPhoneNumberError - | VerifyPhoneNumberResult + union VerifyPhoneNumberUnion = VerifyPhoneNumberError | VerifyPhoneNumberResult # This object is used to inform the user if any errors occurred while verifying their phone number. type VerifyPhoneNumberError { diff --git a/frontend/src/components/ExportButton.js b/frontend/src/components/ExportButton.js index f53c86ad51..7f140369d7 100644 --- a/frontend/src/components/ExportButton.js +++ b/frontend/src/components/ExportButton.js @@ -1,5 +1,5 @@ import React from 'react' -import { Button } from '@chakra-ui/react' +import {Button} from '@chakra-ui/react' import { arrayOf, object, string, func } from 'prop-types' import { json2csvAsync } from 'json-2-csv' import { Trans } from '@lingui/macro' diff --git a/frontend/src/domains/DomainsPage.js b/frontend/src/domains/DomainsPage.js index 3aa7a07a8f..3bfcfd222c 100644 --- a/frontend/src/domains/DomainsPage.js +++ b/frontend/src/domains/DomainsPage.js @@ -1,6 +1,14 @@ import React, { useCallback, useState } from 'react' import { t, Trans } from '@lingui/macro' -import { Box, Heading, Link, Text, useDisclosure } from '@chakra-ui/react' +import { + Box, + Flex, + Heading, + Link, + Text, + useDisclosure, + useToast, +} from '@chakra-ui/react' import { ExternalLinkIcon } from '@chakra-ui/icons' import { ErrorBoundary } from 'react-error-boundary' @@ -13,16 +21,38 @@ import { ErrorFallbackMessage } from '../components/ErrorFallbackMessage' import { LoadingMessage } from '../components/LoadingMessage' import { useDebouncedFunction } from '../utilities/useDebouncedFunction' import { usePaginatedCollection } from '../utilities/usePaginatedCollection' -import { PAGINATED_DOMAINS as FORWARD } from '../graphql/queries' +import { + PAGINATED_DOMAINS as FORWARD, + GET_ALL_ORGANIZATION_DOMAINS_STATUSES_CSV, +} from '../graphql/queries' import { SearchBox } from '../components/SearchBox' +import { useLazyQuery } from '@apollo/client' +import { ExportButton } from '../components/ExportButton' export default function DomainsPage() { + const toast = useToast() const [orderDirection, setOrderDirection] = useState('ASC') const [orderField, setOrderField] = useState('DOMAIN') const [searchTerm, setSearchTerm] = useState('') const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('') const [domainsPerPage, setDomainsPerPage] = useState(10) + const [ + getAllOrgDomainStatuses, + { loading: allOrgDomainStatusesLoading, _error, _data }, + ] = useLazyQuery(GET_ALL_ORGANIZATION_DOMAINS_STATUSES_CSV, { + onError(error) { + toast({ + title: error.message, + description: t`An error occured when you attempted to download all domain statuses.`, + status: 'error', + duration: 9000, + isClosable: true, + position: 'top-left', + }) + }, + }) + const memoizedSetDebouncedSearchTermCallback = useCallback(() => { setDebouncedSearchTerm(searchTerm) }, [searchTerm]) @@ -100,9 +130,49 @@ export default function DomainsPage() { return ( - - Domains - + + + Domains + + + { + toast({ + title: t`Getting domain statuses`, + description: t`Request successfully sent to get all domain statuses - this may take a minute.`, + status: 'info', + duration: 9000, + isClosable: true, + position: 'top-left', + }) + const result = await getAllOrgDomainStatuses() + if (result.data?.getAllOrganizationDomainStatuses === undefined) { + toast({ + title: t`No data found`, + description: t`No data found when retrieving all domain statuses.`, + status: 'error', + duration: 9000, + isClosable: true, + position: 'top-left', + }) + + throw t`No data found` + } + + return result.data?.getAllOrganizationDomainStatuses + }} + isLoading={allOrgDomainStatusesLoading} + /> + diff --git a/frontend/src/graphql/queries.js b/frontend/src/graphql/queries.js index 738b90a8f1..c6d212fd19 100644 --- a/frontend/src/graphql/queries.js +++ b/frontend/src/graphql/queries.js @@ -86,6 +86,12 @@ export const GET_ORGANIZATION_DOMAINS_STATUSES_CSV = gql` } ` +export const GET_ALL_ORGANIZATION_DOMAINS_STATUSES_CSV = gql` + query GetAllOrganizationDomainStatuses { + getAllOrganizationDomainStatuses + } +` + export const GET_ONE_TIME_SCANS = gql` query GetOneTimeScans { getOneTimeScans @client diff --git a/frontend/src/organizationDetails/OrganizationDetails.js b/frontend/src/organizationDetails/OrganizationDetails.js index 4ac06bba53..4158481345 100644 --- a/frontend/src/organizationDetails/OrganizationDetails.js +++ b/frontend/src/organizationDetails/OrganizationDetails.js @@ -114,8 +114,8 @@ export default function OrganizationDetails() { fileName={`${orgName}_${new Date().toLocaleDateString()}_Tracker`} dataFunction={ async () => { - const stuff = await getOrgDomainStatuses() - return stuff.data?.findOrganizationBySlug?.toCsv + const result = await getOrgDomainStatuses() + return result.data?.findOrganizationBySlug?.toCsv } } isLoading={orgDomainStatusesLoading}