Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions packages/admin-portal/src/queries/customBuildQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
94 changes: 54 additions & 40 deletions packages/admin-portal/src/resources/Tally/ListTally.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"]

Expand Down Expand Up @@ -187,23 +189,42 @@ export const ListTally: React.FC<ListAreaProps> = () => {
}
)

const tallySessionIds = useMemo(
() => tallySessions?.map((tallySession) => tallySession.id) ?? [],
[tallySessions]
)

const {data: tallySessionExecutions} = useGetList<Sequent_Backend_Tally_Session_Execution>(
"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,
},
},
{
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
meta: {latestPerTallySession: true},
}
)

const latestExecutionByTallySessionId = useMemo(() => {
const executions = new Map<string, Sequent_Backend_Tally_Session_Execution>()
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<TrusteeNamesQuery>(GET_TRUSTEES_NAMES, {
variables: {
tenantId: tenantId,
Expand Down Expand Up @@ -296,6 +317,15 @@ export const ListTally: React.FC<ListAreaProps> = () => {
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 ? (
Expand Down Expand Up @@ -336,18 +366,15 @@ export const ListTally: React.FC<ListAreaProps> = () => {
record.is_execution_completed,
},
{
icon:
record.execution_status === ITallyExecutionStatus.NOT_STARTED ||
record.execution_status === ITallyExecutionStatus.STARTED ||
record.execution_status === ITallyExecutionStatus.CONNECTED ? (
<Tooltip title={String(t("tallysheet.common.tallyCeremony.addKey"))}>
<TrusteeKeyIcon />
</Tooltip>
) : (
<Tooltip title={String(t("tallysheet.common.tallyCeremony.view"))}>
<DescriptionIcon />
</Tooltip>
),
icon: canTrusteeAct(record) ? (
<Tooltip title={String(t("tallysheet.common.tallyCeremony.addKey"))}>
<TrusteeKeyIcon />
</Tooltip>
) : (
<Tooltip title={String(t("tallysheet.common.tallyCeremony.view"))}>
<DescriptionIcon />
</Tooltip>
),
action: viewTrusteeTally,
showAction: (id: Identifier) => canTrusteeCeremony,
},
Expand Down Expand Up @@ -409,37 +436,26 @@ export const ListTally: React.FC<ListAreaProps> = () => {
}
}

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 (
Expand All @@ -453,16 +469,14 @@ export const ListTally: React.FC<ListAreaProps> = () => {

return (
<>
{canTrusteeCeremony &&
activeCeremony &&
tallySessions?.[0]?.execution_status === "STARTED" ? (
{canTrusteeCeremony && activeCeremony ? (
<Alert severity="info">
<Trans i18nKey="electionEventScreen.tally.notify.participateNow">
{t("tally.invited")}
<NotificationLink
onClick={(e: any) => {
e.preventDefault()
viewTrusteeTally(tallySessions?.[0]?.id)
viewTrusteeTally(activeCeremony.id)
}}
>
click on the tally Key Action
Expand Down
30 changes: 12 additions & 18 deletions packages/admin-portal/src/resources/Tally/TallyCeremonyTrustees.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -48,14 +44,14 @@ export const TallyCeremonyTrustees: React.FC = () => {
const [tenantId] = useTenantStore()
const authContext = useContext(AuthContext)

const [page, setPage] = useState<number>(WizardSteps.Start)
const [page, setPage] = useState<number>(WizardSteps.Status)
const [selectedElections, setSelectedElections] = useState<string[]>([])
const [selectedTrustees, setSelectedTrustees] = useState<boolean>(false)
const [tally, setTally] = useState<Sequent_Backend_Tally_Session>()
const [verified, setVerified] = useState<boolean>(false)
const [uploading, setUploading] = useState<boolean>(false)
const [errors, setErrors] = useState<String | null>(null)
const [trusteeStatus, setTrusteeStatus] = useState<ITrusteeStatus | null>(null)
const [trusteeStatus, setTrusteeStatus] = useState<ITallyTrusteeStatus | null>(null)
const {globalSettings} = useContext(SettingsContext)
const [isTallyCompleted, setIsTallyCompleted] = useState<boolean>(false)

Expand Down Expand Up @@ -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};
Expand Down
39 changes: 38 additions & 1 deletion packages/admin-portal/src/resources/Tally/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
9 changes: 9 additions & 0 deletions packages/admin-portal/src/resources/Tally/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>' type.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// SPDX-FileCopyrightText: 2026 Sequent Tech Inc <legal@sequentech.io>
//
// 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()
})
})
21 changes: 21 additions & 0 deletions packages/admin-portal/src/services/tallyCeremonyParticipation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// SPDX-FileCopyrightText: 2026 Sequent Tech Inc <legal@sequentech.io>
//
// 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
)
}
Loading