diff --git a/packages/admin-portal/src/queries/customBuildQuery.ts b/packages/admin-portal/src/queries/customBuildQuery.ts index 4f24d345654..a527d71c4c8 100644 --- a/packages/admin-portal/src/queries/customBuildQuery.ts +++ b/packages/admin-portal/src/queries/customBuildQuery.ts @@ -402,6 +402,24 @@ export const customBuildQuery = } }, } + } else if ( + resourceName === "sequent_backend_tally_session_execution" && + raFetchType === "GET_LIST" && + params?.meta?.latestPerTallySession + ) { + params.filter = { + ...params.filter, + distinct_on: ["tally_session_id"], + } + const ret = buildQuery(introspectionResults)(raFetchType, resourceName, params) + if (ret?.variables?.order_by) { + ret.variables.order_by = [ + {tally_session_id: "asc"}, + {created_at: "desc"}, + {id: "desc"}, + ] + } + return ret } else if (resourceName === "sequent_backend_tally_sheet" && raFetchType === "GET_LIST") { applyJsonbTextSearchFilter(params.filter, "labels") applyJsonbTextSearchFilter(params.filter, "annotations") diff --git a/packages/admin-portal/src/resources/Tally/ListTally.tsx b/packages/admin-portal/src/resources/Tally/ListTally.tsx index 29edccbeb3c..68a621d655a 100644 --- a/packages/admin-portal/src/resources/Tally/ListTally.tsx +++ b/packages/admin-portal/src/resources/Tally/ListTally.tsx @@ -42,7 +42,7 @@ import {StatusChip} from "@/components/StatusChip" import KeyIcon from "@mui/icons-material/Key" import DoNotDisturbOnIcon from "@mui/icons-material/DoNotDisturbOn" import {theme, IconButton, Dialog} from "@sequentech/ui-essentials" -import {AuthContext, AuthContextValues} from "@/providers/AuthContextProvider" +import {AuthContext} from "@/providers/AuthContextProvider" import {ResourceListStyles} from "@/components/styles/ResourceListStyles" import {faPlus} from "@fortawesome/free-solid-svg-icons" import {EAllowTally} from "@sequentech/ui-core" @@ -65,6 +65,8 @@ import {useKeysPermissions} from "../ElectionEvent/useKeysPermissions" import {GET_TRUSTEES_NAMES} from "@/queries/GetTrusteesNames" import {StyledChip} from "@/components/StyledChip" import {ThreeStateDatagridHeader} from "@/components/ThreeStateDatagridHeader" +import {getTallyTrusteeStatus} from "@/services/tallyCeremonyParticipation" +import {canTrusteeRestorePrivateKey} from "./utils" const OMIT_FIELDS = ["ballot_eml", "trustees"] @@ -187,13 +189,21 @@ export const ListTally: React.FC = () => { } ) + const tallySessionIds = useMemo( + () => tallySessions?.map((tallySession) => tallySession.id) ?? [], + [tallySessions] + ) + const {data: tallySessionExecutions} = useGetList( "sequent_backend_tally_session_execution", { - pagination: {page: 1, perPage: 1}, + pagination: {page: 1, perPage: Math.max(tallySessionIds.length, 1)}, sort: {field: "created_at", order: "DESC"}, filter: { - tally_session_id: tallySessions?.[0]?.id, + tally_session_id: { + format: "hasura-raw-query", + value: {_in: tallySessionIds}, + }, tenant_id: tenantId, }, }, @@ -201,9 +211,20 @@ export const ListTally: React.FC = () => { refetchOnWindowFocus: false, refetchOnReconnect: false, refetchOnMount: false, + meta: {latestPerTallySession: true}, } ) + const latestExecutionByTallySessionId = useMemo(() => { + const executions = new Map() + for (const execution of tallySessionExecutions ?? []) { + if (!executions.has(execution.tally_session_id)) { + executions.set(execution.tally_session_id, execution) + } + } + return executions + }, [tallySessionExecutions]) + const {data: trusteeNames} = useQuery(GET_TRUSTEES_NAMES, { variables: { tenantId: tenantId, @@ -296,6 +317,15 @@ export const ListTally: React.FC = () => { openRecountTallySet(true) } + const canTrusteeAct = (record: RaRecord): boolean => + canTrusteeRestorePrivateKey( + getTallyTrusteeStatus( + latestExecutionByTallySessionId.get(String(record.id)), + authContext.trustee + ), + record.execution_status + ) + const actions = (record: RaRecord) => [ { icon: isTrustee ? ( @@ -336,18 +366,15 @@ export const ListTally: React.FC = () => { record.is_execution_completed, }, { - icon: - record.execution_status === ITallyExecutionStatus.NOT_STARTED || - record.execution_status === ITallyExecutionStatus.STARTED || - record.execution_status === ITallyExecutionStatus.CONNECTED ? ( - - - - ) : ( - - - - ), + icon: canTrusteeAct(record) ? ( + + + + ) : ( + + + + ), action: viewTrusteeTally, showAction: (id: Identifier) => canTrusteeCeremony, }, @@ -409,37 +436,26 @@ export const ListTally: React.FC = () => { } } - const isTrusteeParticipating = ( - tally_session: Sequent_Backend_Tally_Session, - ceremony: Sequent_Backend_Tally_Session_Execution | undefined, - authContext: AuthContextValues - ) => { - if (ceremony) { - let ret = - tally_session.execution_status === ITallyExecutionStatus.STARTED && - !!ceremony.status.trustees.find( - (trustee: any) => trustee.name === authContext.trustee - ) - return ret - } - return false - } - - // Returns a keys ceremony if there's any in which we have been required to - // participate and is active + // Returns an active tally ceremony in which the current trustee must restore a key. const getActiveCeremony = ( tallySessions: Sequent_Backend_Tally_Session[] | undefined, - authContext: AuthContextValues + trusteeName: string | null | undefined ) => { if (!tallySessions) { return } else { return tallySessions.find((tallySession) => - isTrusteeParticipating(tallySession, tallySessionExecutions?.[0], authContext) + canTrusteeRestorePrivateKey( + getTallyTrusteeStatus( + latestExecutionByTallySessionId.get(tallySession.id), + trusteeName + ), + tallySession.execution_status + ) ) } } - let activeCeremony = getActiveCeremony(tallySessions, authContext) + const activeCeremony = getActiveCeremony(tallySessions, authContext.trustee) if (errorCeremonies) { return ( @@ -453,16 +469,14 @@ export const ListTally: React.FC = () => { return ( <> - {canTrusteeCeremony && - activeCeremony && - tallySessions?.[0]?.execution_status === "STARTED" ? ( + {canTrusteeCeremony && activeCeremony ? ( {t("tally.invited")} { e.preventDefault() - viewTrusteeTally(tallySessions?.[0]?.id) + viewTrusteeTally(activeCeremony.id) }} > click on the tally Key Action diff --git a/packages/admin-portal/src/resources/Tally/TallyCeremonyTrustees.tsx b/packages/admin-portal/src/resources/Tally/TallyCeremonyTrustees.tsx index 8f94bc0e3e9..a781d48ff2c 100644 --- a/packages/admin-portal/src/resources/Tally/TallyCeremonyTrustees.tsx +++ b/packages/admin-portal/src/resources/Tally/TallyCeremonyTrustees.tsx @@ -17,12 +17,7 @@ import {useGetList, useGetOne, useRecordContext} from "react-admin" import {WizardStyles} from "@/components/styles/WizardStyles" import {RESTORE_PRIVATE_KEY} from "@/queries/RestorePrivateKey" import {useMutation} from "@apollo/client" -import { - ICeremonyStatus, - ITallyExecutionStatus, - ITallyTrusteeStatus, - ITrusteeStatus, -} from "@/types/ceremonies" +import {ITallyCeremonyStatus, ITallyTrusteeStatus} from "@/types/ceremonies" import {Box} from "@mui/material" import { RestorePrivateKeyMutation, @@ -34,6 +29,7 @@ import { import {AuthContext} from "@/providers/AuthContextProvider" import {useTenantStore} from "@/providers/TenantContextProvider" import {SettingsContext} from "@/providers/SettingsContextProvider" +import {canTrusteeRestorePrivateKey} from "./utils" const WizardSteps = { Start: 0, @@ -48,14 +44,14 @@ export const TallyCeremonyTrustees: React.FC = () => { const [tenantId] = useTenantStore() const authContext = useContext(AuthContext) - const [page, setPage] = useState(WizardSteps.Start) + const [page, setPage] = useState(WizardSteps.Status) const [selectedElections, setSelectedElections] = useState([]) const [selectedTrustees, setSelectedTrustees] = useState(false) const [tally, setTally] = useState() const [verified, setVerified] = useState(false) const [uploading, setUploading] = useState(false) const [errors, setErrors] = useState(null) - const [trusteeStatus, setTrusteeStatus] = useState(null) + const [trusteeStatus, setTrusteeStatus] = useState(null) const {globalSettings} = useContext(SettingsContext) const [isTallyCompleted, setIsTallyCompleted] = useState(false) @@ -122,25 +118,23 @@ export const TallyCeremonyTrustees: React.FC = () => { useEffect(() => { if (tallySessionExecutions) { - const username = authContext?.username - const ceremonyStatus: ICeremonyStatus | undefined = tallySessionExecutions?.[0]?.status + const trusteeName = authContext?.trustee + const ceremonyStatus: ITallyCeremonyStatus | undefined = + tallySessionExecutions?.[0]?.status const trusteeStatus = ceremonyStatus?.trustees.find( - (item) => item.name === username + (item) => item.name === trusteeName )?.status setTrusteeStatus(trusteeStatus ?? null) } - }, [tallySessionExecutions]) + }, [authContext?.trustee, tallySessionExecutions]) useEffect(() => { setPage( - !trusteeStatus && tally?.execution_status !== ITallyExecutionStatus.CANCELLED + canTrusteeRestorePrivateKey(trusteeStatus, tally?.execution_status) ? WizardSteps.Start - : trusteeStatus === ITrusteeStatus.WAITING && - tally?.execution_status !== ITallyExecutionStatus.CANCELLED - ? WizardSteps.Start - : WizardSteps.Status + : WizardSteps.Status ) - }, [trusteeStatus]) + }, [tally?.execution_status, trusteeStatus]) const CancelButton = styled(Button)` background-color: ${({theme}) => theme.palette.white}; diff --git a/packages/admin-portal/src/resources/Tally/utils.test.ts b/packages/admin-portal/src/resources/Tally/utils.test.ts index 5a1c9d0190d..7ac096a995f 100644 --- a/packages/admin-portal/src/resources/Tally/utils.test.ts +++ b/packages/admin-portal/src/resources/Tally/utils.test.ts @@ -2,7 +2,44 @@ // // SPDX-License-Identifier: AGPL-3.0-only -import {orderItemsByIds} from "./utils" +import {ITallyExecutionStatus, ITallyTrusteeStatus} from "@/types/ceremonies" +import {canTrusteeRestorePrivateKey, orderItemsByIds} from "./utils" + +describe("canTrusteeRestorePrivateKey", () => { + it.each([ITallyExecutionStatus.STARTED, ITallyExecutionStatus.CONNECTED])( + "allows a waiting trustee while the tally accepts keys (%s)", + (executionStatus) => { + expect(canTrusteeRestorePrivateKey(ITallyTrusteeStatus.WAITING, executionStatus)).toBe( + true + ) + } + ) + + it.each([ + ITallyExecutionStatus.NOT_STARTED, + ITallyExecutionStatus.IN_PROGRESS, + ITallyExecutionStatus.AWAITING_INPUT, + ITallyExecutionStatus.SUCCESS, + ITallyExecutionStatus.CANCELLED, + ])("does not allow key restoration while the tally is %s", (executionStatus) => { + expect(canTrusteeRestorePrivateKey(ITallyTrusteeStatus.WAITING, executionStatus)).toBe( + false + ) + }) + + it("does not allow a trustee whose key is already restored", () => { + expect( + canTrusteeRestorePrivateKey( + ITallyTrusteeStatus.KEY_RESTORED, + ITallyExecutionStatus.STARTED + ) + ).toBe(false) + }) + + it("does not allow a user who is absent from the tally ceremony", () => { + expect(canTrusteeRestorePrivateKey(null, ITallyExecutionStatus.STARTED)).toBe(false) + }) +}) describe("orderItemsByIds", () => { const items = [ diff --git a/packages/admin-portal/src/resources/Tally/utils.ts b/packages/admin-portal/src/resources/Tally/utils.ts index 594e38389b9..41dee132d2a 100644 --- a/packages/admin-portal/src/resources/Tally/utils.ts +++ b/packages/admin-portal/src/resources/Tally/utils.ts @@ -5,6 +5,15 @@ import {ParsedAnnotations, RunoffStatus} from "./types" import {Sequent_Backend_Candidate, Sequent_Backend_Contest} from "@/gql/graphql" import {ICandidate, IContest, ICountingAlgorithm} from "@sequentech/ui-core" +import {ITallyExecutionStatus, ITallyTrusteeStatus} from "@/types/ceremonies" + +export const canTrusteeRestorePrivateKey = ( + trusteeStatus: ITallyTrusteeStatus | null, + tallyExecutionStatus: string | null | undefined +): boolean => + trusteeStatus === ITallyTrusteeStatus.WAITING && + (tallyExecutionStatus === ITallyExecutionStatus.STARTED || + tallyExecutionStatus === ITallyExecutionStatus.CONNECTED) /** * Safely extracts the value from a GraphQL 'Maybe' type. diff --git a/packages/admin-portal/src/services/tallyCeremonyParticipation.test.ts b/packages/admin-portal/src/services/tallyCeremonyParticipation.test.ts new file mode 100644 index 00000000000..9b19a7f33c3 --- /dev/null +++ b/packages/admin-portal/src/services/tallyCeremonyParticipation.test.ts @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +import {ITallyTrusteeStatus} from "@/types/ceremonies" +import {getTallyTrusteeStatus} from "./tallyCeremonyParticipation" + +const execution = { + status: { + logs: [], + trustees: [ + {name: "trustee-1", status: ITallyTrusteeStatus.WAITING}, + {name: "trustee-2", status: ITallyTrusteeStatus.KEY_RESTORED}, + ], + elections_status: [], + }, +} + +describe("getTallyTrusteeStatus", () => { + it("returns the status for the trustee claim", () => { + expect(getTallyTrusteeStatus(execution, "trustee-2")).toBe(ITallyTrusteeStatus.KEY_RESTORED) + }) + + it.each(["another-trustee", undefined, null])("returns null for non-participant %s", (name) => { + expect(getTallyTrusteeStatus(execution, name)).toBeNull() + }) +}) diff --git a/packages/admin-portal/src/services/tallyCeremonyParticipation.ts b/packages/admin-portal/src/services/tallyCeremonyParticipation.ts new file mode 100644 index 00000000000..663d6cf9075 --- /dev/null +++ b/packages/admin-portal/src/services/tallyCeremonyParticipation.ts @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +import {ITallyCeremonyStatus, ITallyTrusteeStatus} from "@/types/ceremonies" + +interface TallyCeremonyExecutionLike { + status?: ITallyCeremonyStatus | null +} + +export const getTallyTrusteeStatus = ( + execution: TallyCeremonyExecutionLike | undefined, + trusteeName: string | null | undefined +): ITallyTrusteeStatus | null => { + if (!trusteeName) { + return null + } + return ( + execution?.status?.trustees.find((trustee) => trustee.name === trusteeName)?.status ?? null + ) +}