diff --git a/client/src/components/AuditAdmin/__snapshots__/ActivityLog.test.tsx.snap b/client/src/components/AuditAdmin/__snapshots__/ActivityLog.test.tsx.snap
index c4b2c1625..2fbc5a01d 100644
--- a/client/src/components/AuditAdmin/__snapshots__/ActivityLog.test.tsx.snap
+++ b/client/src/components/AuditAdmin/__snapshots__/ActivityLog.test.tsx.snap
@@ -2,7 +2,7 @@
exports[`Activity Log shows a table of activity for the org 1`] = `
diff --git a/client/src/components/AuditBoard/__snapshots__/AuditBoardView.test.tsx.snap b/client/src/components/AuditBoard/__snapshots__/AuditBoardView.test.tsx.snap
index b25800acf..d258bb633 100644
--- a/client/src/components/AuditBoard/__snapshots__/AuditBoardView.test.tsx.snap
+++ b/client/src/components/AuditBoard/__snapshots__/AuditBoardView.test.tsx.snap
@@ -597,7 +597,7 @@ exports[`AuditBoardView ballot interaction renders board table with ballots 1`]
class="sc-GMQeP FVNiK"
>
@@ -2878,7 +2878,7 @@ exports[`AuditBoardView ballot interaction renders board table with no audited b
class="sc-GMQeP FVNiK"
>
@@ -4781,7 +4781,7 @@ exports[`AuditBoardView ballot interaction renders board table with no ballots 1
class="sc-GMQeP FVNiK"
>
@@ -5105,7 +5105,7 @@ exports[`AuditBoardView member form submits, goes to ballot table, and header sh
class="sc-GMQeP FVNiK"
>
diff --git a/client/src/components/SupportTools/Audit.tsx b/client/src/components/SupportTools/Audit.tsx
new file mode 100644
index 000000000..d480a58e2
--- /dev/null
+++ b/client/src/components/SupportTools/Audit.tsx
@@ -0,0 +1,109 @@
+import React from 'react'
+import { Link } from 'react-router-dom'
+import { H2, AnchorButton, Tag, H4 } from '@blueprintjs/core'
+import { useElection, IElection } from './support-api'
+import RoundsTable from './RoundsTable'
+import { List, LinkItem } from './List'
+import Breadcrumbs from './Breadcrumbs'
+import { Column, Row } from './shared'
+
+const prettyAuditType = (auditType: IElection['auditType']) =>
+ ({
+ BALLOT_POLLING: 'Ballot Polling',
+ BALLOT_COMPARISON: 'Ballot Comparison',
+ BATCH_COMPARISON: 'Batch Comparison',
+ HYBRID: 'Hybrid',
+ }[auditType])
+
+const Audit = ({ electionId }: { electionId: string }) => {
+ const election = useElection(electionId)
+
+ if (!election.isSuccess) return null
+
+ const {
+ id,
+ auditName,
+ auditType,
+ organization,
+ jurisdictions,
+ rounds,
+ } = election.data
+
+ return (
+
+
+
+
+ {organization.name}
+
+
+
+
+
+
+
{auditName}
+ {prettyAuditType(auditType)}
+
+
+ Log in as audit admin
+
+
+
+
+
+ Jurisdictions
+
+ {jurisdictions.map(jurisdiction => (
+
+ {jurisdiction.name}
+ e.stopPropagation()}
+ >
+ Log in
+
+
+ ))}
+
+
+
+ Rounds
+
+
+
+
+
+
+
+ )
+}
+
+export default Audit
diff --git a/client/src/components/SupportTools/Jurisdiction.tsx b/client/src/components/SupportTools/Jurisdiction.tsx
new file mode 100644
index 000000000..34ef5a774
--- /dev/null
+++ b/client/src/components/SupportTools/Jurisdiction.tsx
@@ -0,0 +1,321 @@
+import React from 'react'
+import { Link } from 'react-router-dom'
+import { toast } from 'react-toastify'
+import styled from 'styled-components'
+import {
+ H4,
+ Button,
+ Classes,
+ H2,
+ AnchorButton,
+ Intent,
+ Card,
+ MenuItem,
+} from '@blueprintjs/core'
+import { MultiSelect } from '@blueprintjs/select'
+import { useForm, Controller } from 'react-hook-form'
+import {
+ useJurisdiction,
+ useClearAuditBoards,
+ useClearOfflineResults,
+ useJurisdictionBatches,
+ useCreateCombinedBatch,
+ useDeleteCombinedBatch,
+} from './support-api'
+import { useConfirm, Confirm } from '../Atoms/Confirm'
+import AuditBoardsTable from '../AuditAdmin/Progress/AuditBoardsTable'
+import Breadcrumbs from './Breadcrumbs'
+import { Column, Row, Table } from './shared'
+
+const CombinedBatchForm = styled.form`
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+
+ label {
+ font-weight: 500;
+ display: block;
+ margin-bottom: 0.25rem;
+ }
+`
+
+const Jurisdiction = ({ jurisdictionId }: { jurisdictionId: string }) => {
+ const jurisdiction = useJurisdiction(jurisdictionId)
+ const clearAuditBoards = useClearAuditBoards()
+ const clearOfflineResults = useClearOfflineResults()
+ const batches = useJurisdictionBatches(jurisdictionId)
+ const createCombinedBatch = useCreateCombinedBatch()
+ const deleteCombinedBatch = useDeleteCombinedBatch()
+ const { confirm, confirmProps } = useConfirm()
+
+ const { register, handleSubmit, reset, control, formState } = useForm<{
+ name: string
+ subBatchIds: string[]
+ }>()
+
+ if (!jurisdiction.isSuccess) return null
+
+ const {
+ name,
+ organization,
+ election,
+ jurisdictionAdmins,
+ auditBoards,
+ recordedResultsAt,
+ } = jurisdiction.data
+
+ const onClickClearAuditBoards = () => {
+ confirm({
+ title: 'Confirm',
+ description: `Are you sure you want to clear the audit boards for ${name}?`,
+ yesButtonLabel: 'Clear audit boards',
+ onYesClick: async () => {
+ await clearAuditBoards.mutateAsync({ jurisdictionId })
+ toast.success(`Cleared audit boards for ${name}`)
+ },
+ })
+ }
+
+ const onClickClearOfflineResults = () => {
+ confirm({
+ title: 'Confirm',
+ description: `Are you sure you want to clear results for ${name}?`,
+ yesButtonLabel: 'Clear results',
+ onYesClick: async () => {
+ await clearOfflineResults.mutateAsync({
+ jurisdictionId,
+ })
+ toast.success(`Cleared results for ${name}`)
+ },
+ })
+ }
+
+ const onSubmitCreateCombinedBatch = async ({
+ // eslint-disable-next-line no-shadow
+ name,
+ subBatchIds,
+ }: {
+ name: string
+ subBatchIds: string[]
+ }) => {
+ try {
+ await createCombinedBatch.mutateAsync({
+ jurisdictionId,
+ name,
+ subBatchIds,
+ })
+ reset()
+ } catch (error) {
+ // Do nothing - errors toasted by queryClient
+ }
+ }
+
+ return (
+
+
+ {organization.name}
+ {election.auditName}
+
+
{name}
+
+
+ {election.auditType !== 'BATCH_COMPARISON' && (
+ <>
+ Current Round Audit Boards
+ {auditBoards.length === 0 ? (
+ The jurisdiction hasn't created audit boards yet.
+ ) : (
+ <>
+
+
+ >
+ )}
+ >
+ )}
+ {election.auditType === 'BALLOT_POLLING' && !election.online && (
+ <>
+ Offline Results
+ {recordedResultsAt ? (
+ <>
+
+ Results recorded at{' '}
+ {new Date(recordedResultsAt).toLocaleString()}.
+
+
+ >
+ ) : (
+ No results recorded yet.
+ )}
+ >
+ )}
+ {election.auditType === 'BATCH_COMPARISON' && batches.isSuccess && (
+ <>
+ Combined Batches
+
+
+
+
+
+
+
+
+ void
+ }) => (
+
+ value.includes(batch.id)
+ )}
+ onItemSelect={item => {
+ onChange(
+ value.includes(item.id)
+ ? value.filter(id => id !== item.id)
+ : [...value, item.id]
+ )
+ }}
+ onRemove={item => {
+ onChange(
+ value.filter((id: string) => id !== item.id)
+ )
+ }}
+ itemRenderer={(item, { handleClick, modifiers }) => (
+
+ )}
+ tagRenderer={item => item.name}
+ itemPredicate={(query, item) =>
+ item.name
+ .toLowerCase()
+ .includes(query.toLowerCase())
+ }
+ placeholder="Select batches..."
+ resetOnSelect
+ fill
+ popoverProps={{ minimal: true }}
+ tagInputProps={{ tagProps: { minimal: true } }}
+ />
+ )}
+ />
+
+
+
+
+ {batches.data.combinedBatches.length > 0 && (
+
+
+
+ Name |
+ Batches |
+ |
+
+
+
+ {batches.data.combinedBatches.map(combinedBatch => (
+
+ {combinedBatch.name} |
+
+ {combinedBatch.subBatches
+ .map(subBatch => subBatch.name)
+ .join(', ')}
+ |
+
+
+ |
+
+ ))}
+
+
+ )}
+ >
+ )}
+
+
+ Jurisdiction Admins
+
+
+ {jurisdictionAdmins.map(jurisdictionAdmin => (
+
+ {jurisdictionAdmin.email} |
+
+
+ Log in as
+
+ |
+
+ ))}
+
+
+
+
+
+
+ )
+}
+
+export default Jurisdiction
diff --git a/client/src/components/SupportTools/Organization.tsx b/client/src/components/SupportTools/Organization.tsx
new file mode 100644
index 000000000..c87709aa1
--- /dev/null
+++ b/client/src/components/SupportTools/Organization.tsx
@@ -0,0 +1,285 @@
+import React from 'react'
+import { toast } from 'react-toastify'
+import {
+ Button,
+ Classes,
+ H2,
+ AnchorButton,
+ Intent,
+ HTMLSelect,
+ Tag,
+ H4,
+} from '@blueprintjs/core'
+import { useForm } from 'react-hook-form'
+import {
+ useOrganization,
+ useCreateAuditAdmin,
+ IAuditAdmin,
+ useDeleteOrganization,
+ useUpdateOrganization,
+ useRemoveAuditAdmin,
+ useDeleteElection,
+ IElectionBase,
+} from './support-api'
+import { useConfirm, Confirm } from '../Atoms/Confirm'
+import { List, LinkItem } from './List'
+import { stateOptions, states } from '../AuditAdmin/Setup/Settings/states'
+import { sortBy } from '../../utils/array'
+import { AuditStatusTag, Column, Row, Table } from './shared'
+
+const Organization = ({ organizationId }: { organizationId: string }) => {
+ const organization = useOrganization(organizationId)
+ const createAuditAdmin = useCreateAuditAdmin(organizationId)
+ const removeAuditAdmin = useRemoveAuditAdmin(organizationId)
+ const deleteOrganization = useDeleteOrganization(organizationId)
+ const updateOrganization = useUpdateOrganization(organizationId)
+ const deleteElection = useDeleteElection()
+ const { confirm, confirmProps } = useConfirm()
+
+ const {
+ register: registerCreateAdmin,
+ handleSubmit: handleSubmitCreateAdmin,
+ reset: resetCreateAdmin,
+ formState: formStateCreateAdmin,
+ } = useForm()
+ const {
+ register: registerEditOrg,
+ handleSubmit: handleSubmitEditOrg,
+ } = useForm<{ name: string; defaultState?: string | null }>()
+
+ if (!organization.isSuccess) return null
+
+ const onSubmitCreateAuditAdmin = async (auditAdmin: IAuditAdmin) => {
+ try {
+ await createAuditAdmin.mutateAsync(auditAdmin)
+ resetCreateAdmin()
+ toast.success(`Created audit admin: ${auditAdmin.email}`)
+ } catch (error) {
+ // Do nothing - errors toasted by queryClient
+ }
+ }
+
+ const { name, defaultState, elections, auditAdmins } = organization.data
+
+ const sortedElections = sortBy(elections, a =>
+ new Date(a.createdAt).getTime()
+ ).reverse()
+
+ const onClickRemoveAuditAdmin = (auditAdmin: IAuditAdmin) =>
+ confirm({
+ title: 'Confirm',
+ description: `Are you sure you want to remove audit admin ${auditAdmin.email} from organization ${name}?`,
+ yesButtonLabel: 'Remove',
+ onYesClick: async () => {
+ await removeAuditAdmin.mutateAsync({ auditAdminId: auditAdmin.id })
+ toast.success(`Removed audit admin ${auditAdmin.email}`)
+ },
+ })
+
+ const onClickDeleteOrg = () =>
+ confirm({
+ title: 'Confirm',
+ description: `Are you sure you want to delete organization ${name}?`,
+ yesButtonLabel: 'Delete',
+ onYesClick: async () => {
+ await deleteOrganization.mutateAsync()
+ toast.success(`Deleted organization ${name}`)
+ },
+ })
+
+ const onClickEditOrg = () =>
+ confirm({
+ title: 'Edit Organization',
+ description: (
+
+ ),
+ yesButtonLabel: 'Submit',
+ onYesClick: handleSubmitEditOrg(async values => {
+ await updateOrganization.mutateAsync({
+ name: values.name,
+ defaultState: values.defaultState || null,
+ })
+ }),
+ })
+
+ const onClickPermanentlyDeleteAudit = ({ auditName, id }: IElectionBase) => {
+ confirm({
+ title: 'Confirm',
+ description: `Are you sure you want to permanently delete ${auditName}?`,
+ yesButtonLabel: 'Delete',
+ onYesClick: async () => {
+ await deleteElection.mutateAsync({ electionId: id, organizationId })
+ toast.success(`Deleted ${auditName}`)
+ },
+ })
+ }
+
+ return (
+
+
+
{name}
+
+
+
+
+
+
+ {`Default State: ${defaultState ? states[defaultState] : 'None'}`}
+
+
+
+ Audits
+
+ {sortedElections
+ .filter(election => !election.deletedAt)
+ .map(election => {
+ return (
+
+ {election.auditName}
+
+
+ )
+ })}
+
+ Deleted Audits
+
+
+ {elections
+ .filter(election => election.deletedAt)
+ .map(election => (
+
+ {election.auditName} |
+
+
+ |
+
+ ))}
+
+
+
+
+ Audit Admins
+
+
+
+ {auditAdmins.map(auditAdmin => (
+
+ {auditAdmin.email} |
+
+
+ Log in as
+
+
+ |
+
+ ))}
+
+
+
+
+
+
+ )
+}
+
+export default Organization
diff --git a/client/src/components/SupportTools/SupportTools.tsx b/client/src/components/SupportTools/SupportTools.tsx
index dd227b63c..fa830882f 100644
--- a/client/src/components/SupportTools/SupportTools.tsx
+++ b/client/src/components/SupportTools/SupportTools.tsx
@@ -1,74 +1,23 @@
import React, { useState } from 'react'
-import { Redirect, Route, Switch, Link } from 'react-router-dom'
+import { Redirect, Route, Switch } from 'react-router-dom'
import { toast } from 'react-toastify'
import styled from 'styled-components'
-import {
- H3,
- Button,
- HTMLTable,
- Classes,
- H2,
- AnchorButton,
- Tag,
- Intent,
- HTMLSelect,
- Card,
- MenuItem,
- Tooltip,
-} from '@blueprintjs/core'
-import { MultiSelect } from '@blueprintjs/select'
-import { useForm, Controller } from 'react-hook-form'
+import { H3, Button, Classes, AnchorButton, Tooltip } from '@blueprintjs/core'
+import { useForm } from 'react-hook-form'
import { useAuthDataContext } from '../UserContext'
import { Wrapper, SupportToolsInner } from '../Atoms/Wrapper'
import {
useOrganizations,
- useOrganization,
- useCreateAuditAdmin,
- useElection,
- IAuditAdmin,
- IElection,
useCreateOrganization,
- useJurisdiction,
- useClearAuditBoards,
- useClearOfflineResults,
- useDeleteOrganization,
- useUpdateOrganization,
- useRemoveAuditAdmin,
- useDeleteElection,
- IElectionBase,
useActiveElections,
- useJurisdictionBatches,
- useCreateCombinedBatch,
- useDeleteCombinedBatch,
- IRound,
} from './support-api'
-import { useConfirm, Confirm } from '../Atoms/Confirm'
-import AuditBoardsTable from '../AuditAdmin/Progress/AuditBoardsTable'
-import RoundsTable from './RoundsTable'
import { List, LinkItem } from './List'
-import Breadcrumbs from './Breadcrumbs'
-import { stateOptions, states } from '../AuditAdmin/Setup/Settings/states'
-import StatusTag from '../Atoms/StatusTag'
-import { sortBy } from '../../utils/array'
+import Audit from './Audit'
+import Organization from './Organization'
+import Jurisdiction from './Jurisdiction'
+import { AuditStatusTag, Row } from './shared'
import { FilterInput } from '../Atoms/Table'
-const Table = styled(HTMLTable)`
- margin: 10px 0;
- width: 100%;
- table-layout: fixed;
- td:first-child {
- overflow: hidden;
- text-overflow: ellipsis;
- }
- td:last-child:not(:first-child) {
- padding-right: 15px;
- text-align: right;
- }
- tr td {
- vertical-align: baseline;
- }
-`
-
const SupportTools: React.FC = () => {
const auth = useAuthDataContext()
if (!auth) return null // Still loading
@@ -92,15 +41,17 @@ const SupportTools: React.FC = () => {
- {({ match }) => (
+ {({ match }: any) => (
)}
- {({ match }) => }
+ {({ match }: any) => (
+
+ )}
- {({ match }) => (
+ {({ match }: any) => (
)}
@@ -110,31 +61,6 @@ const SupportTools: React.FC = () => {
)
}
-const Column = styled.div`
- width: 50%;
- padding-right: 30px;
-`
-
-const Row = styled.div`
- display: flex;
- gap: 30px;
- width: 100%;
-`
-
-const AuditStatusTag = ({ currentRound }: { currentRound: IRound | null }) => {
- if (!currentRound) {
- return Not Started
- }
- if (currentRound.endedAt) {
- return Completed
- }
- return (
-
- Round {currentRound.roundNum} In Progress
-
- )
-}
-
const ActiveAudits = () => {
const elections = useActiveElections()
@@ -237,10 +163,7 @@ const Tools = () => {
}}
>