diff --git a/docs/docusaurus/docs/02-election_managers/02-reference/02-election-event/03-election_management_election-event_data.md b/docs/docusaurus/docs/02-election_managers/02-reference/02-election-event/03-election_management_election-event_data.md index a20ed5fe9ad..0afe12668ce 100644 --- a/docs/docusaurus/docs/02-election_managers/02-reference/02-election-event/03-election_management_election-event_data.md +++ b/docs/docusaurus/docs/02-election_managers/02-reference/02-election-event/03-election_management_election-event_data.md @@ -51,6 +51,10 @@ Manage language options for your Election Event. The selected languages will be - Use radio buttons to select the languages available. - Set the default language by selecting **Default** next to the appropriate language. +- **Language Detection Policy**: + Affects the default language in the Voting Portal. + - **Browser Detect**: The default language will be determined by the browser. + - **Force Default**: The default language will be the one selected as **Default**. ## Ballot Design diff --git a/docs/docusaurus/docs/02-election_managers/02-reference/06-Languages.md b/docs/docusaurus/docs/02-election_managers/02-reference/06-Languages.md new file mode 100644 index 00000000000..095ec695923 --- /dev/null +++ b/docs/docusaurus/docs/02-election_managers/02-reference/06-Languages.md @@ -0,0 +1,76 @@ +--- +id: languages +title: Languages +--- + + +## Language Determination in the Voting Portal + +In the voting portal, the system determines which language to display to voters based on a defined order. It respects user preferences while allowing administrators to enforce language policies when needed. + +### Language Determination Priority + +The language is determined in the following order of precedence (highest to lowest): + +1. **URL Search Parameter (`lang`)** — Highest priority +2. **User Selected Locale in the login flow** — Saved in browser cookie +3. **Language Detection Policy** — Configured in election event Data tab +4. **Browser Settings** — Browser language preference (lowest priority) + +### How Each Level Works + +#### 1. URL Search Parameter (`lang`) + +When a voter accesses the voting portal with a `lang` query parameter, it takes absolute precedence over all other settings. This allows you to direct voters to a specific language version via links. + +**Example:** +``` +https://myelection.sequent.vote/?lang=es +``` + +The `lang` parameter is checked during i18n initialization, ensuring the language is applied before any policies are evaluated. + +#### 2. User Selected Locale + +Once a voter selects a language in the voting portal, their choice is saved in a browser cookie. This cookie is stored for the duration of the session (until the browser is closed). On subsequent visits within the same session, the saved language preference is restored. + +This allows voters to: +- Select their preferred language once +- Override the language detection policy for their individual session + +#### 3. Language Detection Policy + +The Language Detection Policy is configured at the election event level and determines how the system selects a language when no URL parameter or user preference cookie exists. + +**Available Policies:** + +- **`BROWSER_DETECT`** (default) — The voting portal automatically detects the voter's browser language and displays content in the closest available language +- **`FORCE_DEFAULT`** — All voters are shown the default language specified in the election settings, regardless of their browser language + +**When this policy applies:** +- Only when no `lang` URL parameter is present +- Only when the user hasn’t manually changed the language. + + +### Example Scenarios + +**Scenario 1: Multi-language election with browser detection** +- No language policy is set +- Voter 1 with Spanish browser settings sees Spanish immediately +- Voter 2 with English browser settings sees English immediately +- Both voters can manually select a different language and their choice is saved + +**Scenario 2: Bilingual election with forced default** +- Language policy is set to `FORCE_DEFAULT` with `default_language_code: es` +- All voters see Spanish, regardless of their browser settings +- Voters can still choose a different language manually via the UI + +**Scenario 3: Election with persistent user preference** +- No language policy is set +- Voter visits the portal in Spanish and selects English manually +- English preference is saved to a cookie +- The `lang` URL parameter still overrides this if provided + diff --git a/docs/docusaurus/docs/02-election_managers/02-reference/user-manual/settings/settings_languages.md b/docs/docusaurus/docs/02-election_managers/02-reference/user-manual/settings/settings_languages.md index 6a2dbf50bcd..bd008df9fdc 100644 --- a/docs/docusaurus/docs/02-election_managers/02-reference/user-manual/settings/settings_languages.md +++ b/docs/docusaurus/docs/02-election_managers/02-reference/user-manual/settings/settings_languages.md @@ -8,9 +8,10 @@ title: Languages SPDX-License-Identifier: AGPL-3.0-only --> +Manage language options for the Tenant in the Admin Portal. - - -This is a placeholder page for the section: Languages. - -Content will be added here soon. +- Use radio buttons to select the languages available. +- Set the default language by selecting **Default** next to the appropriate language. +- **Language Detection Policy**: + - **Browser Detect**: The default language will be determined by the browser. + - **Force Default**: The default language will be the one selected as **Default**. diff --git a/packages/admin-portal/rust/sequent-core-0.1.0.tgz b/packages/admin-portal/rust/sequent-core-0.1.0.tgz index bc6c9361075..a4d5fa5d536 100644 Binary files a/packages/admin-portal/rust/sequent-core-0.1.0.tgz and b/packages/admin-portal/rust/sequent-core-0.1.0.tgz differ diff --git a/packages/admin-portal/src/components/CustomLayout.tsx b/packages/admin-portal/src/components/CustomLayout.tsx index ee81d9d09f4..37673c6ffd0 100644 --- a/packages/admin-portal/src/components/CustomLayout.tsx +++ b/packages/admin-portal/src/components/CustomLayout.tsx @@ -11,7 +11,7 @@ import {Sequent_Backend_Tenant} from "@/gql/graphql" import {useGetOne} from "react-admin" import cssInputLookAndFeel from "@/atoms/css-input-look-and-feel" import {useAtomValue, useSetAtom} from "jotai" -import {ITenantTheme} from "@sequentech/ui-core" +import {applyLanguagePolicy, ITenantTheme} from "@sequentech/ui-core" import {ImportDataDrawer} from "./election-event/import-data/ImportDataDrawer" import { CreateElectionEventProvider, @@ -34,6 +34,13 @@ export const CustomCssReader: React.FC = () => { } }, [tenantData?.annotations?.css, setAtomValue, css]) + useEffect(() => { + const languageConf = tenantData?.settings?.language_conf + if (languageConf) { + applyLanguagePolicy(languageConf) + } + }, [tenantData?.settings?.language_conf]) + return <> } diff --git a/packages/admin-portal/src/components/SettingsLanguageSelector.tsx b/packages/admin-portal/src/components/SettingsLanguageSelector.tsx new file mode 100644 index 00000000000..fba92dd936c --- /dev/null +++ b/packages/admin-portal/src/components/SettingsLanguageSelector.tsx @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only +import {Box, Radio, Typography} from "@mui/material" +import {BooleanInput, useInput} from "react-admin" +import {useTranslation} from "react-i18next" + +type SettingsLanguageSelectorProps = { + languageSettings: string[] + canEdit?: boolean +} + +export const SettingsLanguageSelector = ({ + languageSettings, + canEdit = true, +}: SettingsLanguageSelectorProps) => { + const {t} = useTranslation() + + const { + field: {value: defaultLanguage, onChange: onDefaultLanguageChange}, + } = useInput({ + source: "presentation.language_conf.default_language_code", + }) + + return ( + + {languageSettings.map((lang) => ( + + + + + onDefaultLanguageChange(lang)} + disabled={!canEdit} + /> + {String(t("electionScreen.edit.default"))} + + + ))} + + ) +} diff --git a/packages/admin-portal/src/resources/Election/ElectionDataForm.tsx b/packages/admin-portal/src/resources/Election/ElectionDataForm.tsx index 9ddcad76293..c8aa56f64fd 100644 --- a/packages/admin-portal/src/resources/Election/ElectionDataForm.tsx +++ b/packages/admin-portal/src/resources/Election/ElectionDataForm.tsx @@ -9,7 +9,6 @@ import { useRecordContext, SimpleForm, useGetOne, - RadioButtonGroupInput, Toolbar, SaveButton, useNotify, @@ -82,6 +81,7 @@ import {MANAGE_ELECTION_DATES} from "@/queries/ManageElectionDates" import {JsonEditor, UpdateFunction} from "json-edit-react" import {CustomFilter} from "@/types/filters" import {useGetDocumentUrl} from "@/hooks/useGetDocumentUrl" +import {SettingsLanguageSelector} from "@/components/SettingsLanguageSelector" const LangsWrapper = styled(Box)` margin-top: 46px; @@ -329,37 +329,6 @@ export const ElectionDataForm: React.FC = () => { setValue(newValue) } - const renderLangs = (parsedValue: Sequent_Backend_Election_Extended) => { - return ( - - {languageSettings.map((lang) => ( - - ))} - - ) - } - - const renderDefaultLangs = (_parsedValue: Sequent_Backend_Election_Extended) => { - let langNodes = languageSettings.map((lang) => ({ - id: lang, - name: t(`electionScreen.edit.default`), - })) - - return ( - - ) - } - const renderVotingChannels = (parsedValue: Sequent_Backend_Election_Extended) => { let channelNodes = [] for (const channel in parsedValue?.voting_channels) { @@ -652,8 +621,10 @@ export const ElectionDataForm: React.FC = () => { - {renderLangs(parsedValue)} - {renderDefaultLangs(parsedValue)} + diff --git a/packages/admin-portal/src/resources/ElectionEvent/EditElectionEventData.tsx b/packages/admin-portal/src/resources/ElectionEvent/EditElectionEventData.tsx index d9b26402d28..8632055a528 100644 --- a/packages/admin-portal/src/resources/ElectionEvent/EditElectionEventData.tsx +++ b/packages/admin-portal/src/resources/ElectionEvent/EditElectionEventData.tsx @@ -85,6 +85,8 @@ export const EditElectionEventData: React.FC = () => { language_conf: { ...language_conf, default_language_code: data?.presentation?.language_conf?.default_language_code, + language_detection_policy: + data?.presentation?.language_conf?.language_detection_policy, }, }, } diff --git a/packages/admin-portal/src/resources/ElectionEvent/EditElectionEventDataForm.tsx b/packages/admin-portal/src/resources/ElectionEvent/EditElectionEventDataForm.tsx index 9508b8ec4c2..d5dc9ba5deb 100644 --- a/packages/admin-portal/src/resources/ElectionEvent/EditElectionEventDataForm.tsx +++ b/packages/admin-portal/src/resources/ElectionEvent/EditElectionEventDataForm.tsx @@ -12,7 +12,6 @@ import { Identifier, useEditController, useRecordContext, - RadioButtonGroupInput, useNotify, Button, SelectInput, @@ -58,6 +57,8 @@ import { EElectionEventCeremoniesPolicy, EElectionEventWeightedVotingPolicy, EElectionEventDelegatedVotingPolicy, + ELanguageDetectionPolicy, + getDefaultLanguageDetectionPolicy, } from "@sequentech/ui-core" import {ListActions} from "@/components/ListActions" import {ImportDataDrawer} from "@/components/election-event/import-data/ImportDataDrawer" @@ -90,6 +91,7 @@ import {JsonEditor, UpdateFunction} from "json-edit-react" import {CustomFilter} from "@/types/filters" import {SET_VOTER_AOTHENTICATION} from "@/queries/SetVoterAuthentication" import {GoogleMeetLinkGenerator} from "@/components/election-event/google-meet/GoogleMeetLinkGenerator" +import {SettingsLanguageSelector} from "../../components/SettingsLanguageSelector" export type Sequent_Backend_Election_Event_Extended = RaRecord & { enabled_languages?: {[key: string]: boolean} @@ -297,37 +299,6 @@ export const EditElectionEventDataForm: React.FC = () => { return errors } - const renderDefaultLangs = (_parsedValue: Sequent_Backend_Election_Event_Extended) => { - let langNodes = languageSettings.map((lang) => ({ - id: lang, - name: t(`electionScreen.edit.default`), - })) - - return ( - - ) - } - - const renderLangs = (parsedValue: Sequent_Backend_Election_Event_Extended) => { - return ( - - {languageSettings.map((lang) => ( - - ))} - - ) - } - const renderVotingChannels = (parsedValue: Sequent_Backend_Election_Event_Extended) => { let channelNodes = [] for (const channel in parsedValue?.voting_channels) { @@ -574,6 +545,13 @@ export const EditElectionEventDataForm: React.FC = () => { })) } + const languageDetectionPolicyOptions = () => { + return Object.values(ELanguageDetectionPolicy).map((value) => ({ + id: value, + name: t(`electionEventScreen.field.languageDetectionPolicy.options.${value}`), + })) + } + type UpdateFunctionProps = Parameters[0] const updateCustomFilters = ( @@ -704,6 +682,8 @@ export const EditElectionEventDataForm: React.FC = () => { } const onSave = async () => { + console.log(parsedValue.presentation) + await handleUpdateCustomUrls( parsedValue.presentation as IElectionEventPresentation, record?.id @@ -742,9 +722,7 @@ export const EditElectionEventDataForm: React.FC = () => { {canEdit && ( { - onSave() - }} + onClick={onSave} type="button" alwaysEnable={activateSave} /> @@ -803,8 +781,23 @@ export const EditElectionEventDataForm: React.FC = () => { - {renderLangs(parsedValue)} - {renderDefaultLangs(parsedValue)} + + + + diff --git a/packages/admin-portal/src/resources/Settings/SettingsLanguages.tsx b/packages/admin-portal/src/resources/Settings/SettingsLanguages.tsx index 0951adaf4e4..a7ce612bfd6 100644 --- a/packages/admin-portal/src/resources/Settings/SettingsLanguages.tsx +++ b/packages/admin-portal/src/resources/Settings/SettingsLanguages.tsx @@ -3,12 +3,18 @@ // SPDX-License-Identifier: AGPL-3.0-only import React, {useEffect, useState} from "react" import {styled} from "@mui/material/styles" -import {Switch, Typography} from "@mui/material" +import {Switch, Typography, Select, MenuItem, InputLabel, FormControl} from "@mui/material" import {useTranslation} from "react-i18next" import {useEditController} from "react-admin" import {useTenantStore} from "@/providers/TenantContextProvider" -import {ILanguageConf, ITenantSettings, getLanguages} from "@sequentech/ui-core" +import { + ELanguageDetectionPolicy, + ILanguageConf, + ITenantSettings, + getDefaultLanguageDetectionPolicy, + getLanguages, +} from "@sequentech/ui-core" const SettingsLanguagesStyles = { Wrapper: styled("div")` @@ -40,15 +46,65 @@ export const SettingsLanguages: React.FC = () => { const defaultLanguageConf: ILanguageConf = { enabled_language_codes: ["en"], default_language_code: "en", + language_detection_policy: getDefaultLanguageDetectionPolicy(), } const [languageConf, setLanguageConf] = useState( (record?.settings as ITenantSettings | undefined)?.language_conf ?? defaultLanguageConf ) + const [defaultLanguage, setDefaultLanguage] = useState( + languageConf.default_language_code ?? "en" + ) + + const [languageDetectionPolicy, setLanguageDetectionPolicy] = + useState( + languageConf.language_detection_policy ?? getDefaultLanguageDetectionPolicy() + ) + const checkIncludesLang = (lang: string) => languageConf.enabled_language_codes?.includes(lang) ?? false + const enabledLanguagesList = listLangs.filter((lang: string) => + languageConf.enabled_language_codes?.includes(lang) + ) + + const onDefaultLanguageChange = (lang: string) => { + setDefaultLanguage(lang) + const updatedLanguageConf = { + ...languageConf, + default_language_code: lang, + } + setLanguageConf(updatedLanguageConf) + + if (save) { + save({ + settings: { + ...((record?.settings as ITenantSettings | undefined) ?? {}), + language_conf: updatedLanguageConf, + }, + }) + } + } + + const onLanguageDetectionPolicyChange = (policy: ELanguageDetectionPolicy) => { + setLanguageDetectionPolicy(policy) + const updatedLanguageConf = { + ...languageConf, + language_detection_policy: policy, + } + setLanguageConf(updatedLanguageConf) + + if (save) { + save({ + settings: { + ...((record?.settings as ITenantSettings | undefined) ?? {}), + language_conf: updatedLanguageConf, + }, + }) + } + } + const handleToggle = (lang: string) => { const includesLang = checkIncludesLang(lang) @@ -84,6 +140,11 @@ export const SettingsLanguages: React.FC = () => { } }, [record]) + const languageDetectionPolicyOptions = Object.values(ELanguageDetectionPolicy).map((value) => ({ + id: value, + name: t(`electionEventScreen.field.languageDetectionPolicy.options.${value}`), + })) + if (isLoading) return null return ( @@ -96,10 +157,48 @@ export const SettingsLanguages: React.FC = () => { {t("language", {lng: lang})} - handleToggle(lang)} /> ))} + + {t("settings.languages.default")} + + + + + + {t("electionEventScreen.field.languageDetectionPolicy.policyLabel")} + + + ) } diff --git a/packages/admin-portal/src/translations/cat.ts b/packages/admin-portal/src/translations/cat.ts index 3008f53fa57..e8b213bcff4 100644 --- a/packages/admin-portal/src/translations/cat.ts +++ b/packages/admin-portal/src/translations/cat.ts @@ -381,6 +381,13 @@ const catalanTranslation: TranslationType = { disabled: "Desactivada", }, }, + languageDetectionPolicy: { + policyLabel: "Política de detecció de llengua", + options: { + "browser-detect": "Detectar del navegador", + "force-default": "Forçar per defecte", + }, + }, }, error: { endDate: "La data de finalització ha de ser posterior a la data d'inici", @@ -2102,6 +2109,9 @@ const catalanTranslation: TranslationType = { url: "URL", }, }, + languages: { + default: "Llengua per defecte", + }, }, approvalsScreen: { column: { diff --git a/packages/admin-portal/src/translations/en.ts b/packages/admin-portal/src/translations/en.ts index 58d4b7ce454..62847a183b8 100644 --- a/packages/admin-portal/src/translations/en.ts +++ b/packages/admin-portal/src/translations/en.ts @@ -2,6 +2,8 @@ // // SPDX-License-Identifier: AGPL-3.0-only +import {de} from "intl-tel-input/i18n" + const englishTranslation = { translations: { philippinePassport: "Philippine Passport", @@ -380,6 +382,13 @@ const englishTranslation = { disabled: "Disabled", }, }, + languageDetectionPolicy: { + policyLabel: "Language Detection Policy", + options: { + "browser-detect": "Browser Detect", + "force-default": "Force Default", + }, + }, }, error: { endDate: "End date must be after start date", @@ -2076,6 +2085,9 @@ const englishTranslation = { url: "URL", }, }, + languages: { + default: "Default Language", + }, }, approvalsScreen: { column: { diff --git a/packages/admin-portal/src/translations/es.ts b/packages/admin-portal/src/translations/es.ts index bd30b7b73df..1d9b9ea0951 100644 --- a/packages/admin-portal/src/translations/es.ts +++ b/packages/admin-portal/src/translations/es.ts @@ -382,6 +382,13 @@ const spanishTranslation: TranslationType = { disabled: "Deshabilitado", }, }, + languageDetectionPolicy: { + policyLabel: "Política de detección de idioma", + options: { + "browser-detect": "Detectar desde el navegador", + "force-default": "Forzar predeterminado", + }, + }, }, error: { endDate: "La fecha de finalización debe ser posterior a la fecha de inicio", @@ -2095,6 +2102,9 @@ const spanishTranslation: TranslationType = { url: "URL", }, }, + languages: { + default: "Idioma predeterminado", + }, }, approvalsScreen: { column: { diff --git a/packages/admin-portal/src/translations/eu.ts b/packages/admin-portal/src/translations/eu.ts index 47fea536097..846cdebfe17 100644 --- a/packages/admin-portal/src/translations/eu.ts +++ b/packages/admin-portal/src/translations/eu.ts @@ -381,6 +381,13 @@ const basqueTranslation: TranslationType = { disabled: "Desgaituta", }, }, + languageDetectionPolicy: { + policyLabel: "Hizkuntza detekzio politika", + options: { + "browser-detect": "Arakatzailetik detektatu", + "force-default": "Lehenetsia behartu", + }, + }, }, error: { endDate: "Amaiera data hasiera data baino beranduagokoa izan behar da", @@ -2085,6 +2092,9 @@ const basqueTranslation: TranslationType = { url: "URLa", }, }, + languages: { + default: "Lehenetsitako hizkuntza", + }, }, approvalsScreen: { column: { diff --git a/packages/admin-portal/src/translations/fr.ts b/packages/admin-portal/src/translations/fr.ts index ae630c8d278..ea928ed1622 100644 --- a/packages/admin-portal/src/translations/fr.ts +++ b/packages/admin-portal/src/translations/fr.ts @@ -381,6 +381,13 @@ const frenchTranslation: TranslationType = { disabled: "Désactivé", }, }, + languageDetectionPolicy: { + policyLabel: "Politique de détection de la langue", + options: { + "browser-detect": "Détection par le navigateur", + "force-default": "Forcer par défaut", + }, + }, }, error: { endDate: "La date de fin doit être postérieure à la date de début", @@ -2106,6 +2113,9 @@ const frenchTranslation: TranslationType = { url: "URL", }, }, + languages: { + default: "Langue par défaut", + }, }, approvalsScreen: { column: { diff --git a/packages/admin-portal/src/translations/gl.ts b/packages/admin-portal/src/translations/gl.ts index 1a3d554bca8..e27b0d887b6 100644 --- a/packages/admin-portal/src/translations/gl.ts +++ b/packages/admin-portal/src/translations/gl.ts @@ -381,6 +381,13 @@ const galegoTranslation: TranslationType = { disabled: "Desactivado", }, }, + languageDetectionPolicy: { + policyLabel: "Política de detección de idioma", + options: { + "browser-detect": "Detectar desde o navegador", + "force-default": "Forzar predeterminado", + }, + }, }, error: { endDate: "A data de fin debe ser posterior á data de inicio", @@ -2092,6 +2099,9 @@ const galegoTranslation: TranslationType = { url: "URL", }, }, + languages: { + default: "Idioma predeterminado", + }, }, approvalsScreen: { column: { diff --git a/packages/admin-portal/src/translations/nl.ts b/packages/admin-portal/src/translations/nl.ts index bfed26bbd50..2a727ab56b2 100644 --- a/packages/admin-portal/src/translations/nl.ts +++ b/packages/admin-portal/src/translations/nl.ts @@ -379,6 +379,13 @@ const dutchTranslation: TranslationType = { disabled: "Uitgeschakeld", }, }, + languageDetectionPolicy: { + policyLabel: "Taaldetectiebeleid", + options: { + "browser-detect": "Detecteren via browser", + "force-default": "Standaard afdwingen", + }, + }, }, error: { endDate: "Einddatum moet na startdatum liggen", @@ -2088,6 +2095,9 @@ const dutchTranslation: TranslationType = { url: "URL", }, }, + languages: { + default: "Standaardtaal", + }, }, approvalsScreen: { column: { diff --git a/packages/admin-portal/src/translations/tl.ts b/packages/admin-portal/src/translations/tl.ts index 24b7f0cef96..1d8d2548d3d 100644 --- a/packages/admin-portal/src/translations/tl.ts +++ b/packages/admin-portal/src/translations/tl.ts @@ -380,6 +380,13 @@ const tagalogTranslation: TranslationType = { disabled: "Hindi pinagana", }, }, + languageDetectionPolicy: { + policyLabel: "Patakaran sa Pag-detect ng Wika", + options: { + "browser-detect": "Awtomatikong tuklasin mula sa browser", + "force-default": "Ipatupad ang default", + }, + }, }, error: { endDate: "Ang pagtatapos na petsa ay dapat pagkalipas ng petsa ng pagsisimula", @@ -2098,6 +2105,9 @@ const tagalogTranslation: TranslationType = { url: "URL", }, }, + languages: { + default: "Default na Wika", + }, }, approvalsScreen: { column: { diff --git a/packages/ballot-verifier/rust/sequent-core-0.1.0.tgz b/packages/ballot-verifier/rust/sequent-core-0.1.0.tgz index bc6c9361075..a4d5fa5d536 100644 Binary files a/packages/ballot-verifier/rust/sequent-core-0.1.0.tgz and b/packages/ballot-verifier/rust/sequent-core-0.1.0.tgz differ diff --git a/packages/ballot-verifier/src/App.tsx b/packages/ballot-verifier/src/App.tsx index b28cda1a79b..4e4caa9b6e7 100644 --- a/packages/ballot-verifier/src/App.tsx +++ b/packages/ballot-verifier/src/App.tsx @@ -5,7 +5,7 @@ import React, {useContext, useEffect, useMemo, useState} from "react" import {Routes, Route, useNavigate, Navigate} from "react-router-dom" import {styled} from "@mui/material/styles" import {Footer, Header, NotFoundScreen, PageBanner} from "@sequentech/ui-essentials" -import {IElectionEventPresentation} from "@sequentech/ui-core" +import {applyPresentationLanguagePolicy, IElectionEventPresentation} from "@sequentech/ui-core" import {HomeScreen} from "./screens/HomeScreen" import {ConfirmationScreen} from "./screens/ConfirmationScreen" import Stack from "@mui/material/Stack" diff --git a/packages/ballot-verifier/src/services/i18n.ts b/packages/ballot-verifier/src/services/i18n.ts index d17a4d65850..112b688c861 100644 --- a/packages/ballot-verifier/src/services/i18n.ts +++ b/packages/ballot-verifier/src/services/i18n.ts @@ -9,11 +9,19 @@ import frenchTranslation from "../translations/fr" import tagalogTranslation from "../translations/tl" import galegoTranslation from "../translations/gl" -initializeLanguages({ - en: englishTranslation, - es: spanishTranslation, - cat: catalanTranslation, - fr: frenchTranslation, - tl: tagalogTranslation, - gl: galegoTranslation, -}) +export const getLanguageFromURL = () => { + const params = new URLSearchParams(window.location.search) + return params.get("lang") || undefined +} + +initializeLanguages( + { + en: englishTranslation, + es: spanishTranslation, + cat: catalanTranslation, + fr: frenchTranslation, + tl: tagalogTranslation, + gl: galegoTranslation, + }, + getLanguageFromURL() +) diff --git a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/template.ftl b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/template.ftl index c7cefc2252f..fb6cc0a1fb5 100644 --- a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/template.ftl +++ b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/template.ftl @@ -99,7 +99,7 @@ SPDX-License-Identifier: AGPL-3.0-only <#assign i = 1> <#list locale.supported as l>
  • - ${l.label} + ${l.label}
  • <#assign i++> @@ -216,6 +216,42 @@ SPDX-License-Identifier: AGPL-3.0-only

    ${kcSanitize(msg("loginFooter"))?no_esc}

    + + diff --git a/packages/sequent-core/src/ballot.rs b/packages/sequent-core/src/ballot.rs index b110f11328d..4918d60ec94 100644 --- a/packages/sequent-core/src/ballot.rs +++ b/packages/sequent-core/src/ballot.rs @@ -948,6 +948,7 @@ pub struct ElectionEventMaterials { pub struct ElectionEventLanguageConf { pub enabled_language_codes: Option>, pub default_language_code: Option, + pub language_detection_policy: Option, } #[derive( @@ -2593,3 +2594,32 @@ pub enum TieBreakingPolicy { #[serde(rename = "external-procedure")] EXTERNAL_PROCEDURE, } + +#[allow(non_camel_case_types)] +#[derive( + BorshSerialize, + BorshDeserialize, + Default, + Display, + Serialize, + Deserialize, + Debug, + PartialEq, + Eq, + Clone, + EnumString, + JsonSchema, +)] +/// Language detection policy. +/// Used to determine which language to use initially across all surfaces +pub enum LanguageDetectionPolicy { + #[default] + #[strum(serialize = "browser-detect")] + #[serde(rename = "browser-detect")] + /// detect user's language through their browser. + BROWSER_DETECT, + /// skip browser detection, use default_language_code + #[strum(serialize = "force-default")] + #[serde(rename = "force-default")] + FORCE_DEFAULT, +} diff --git a/packages/sequent-core/src/services/s3.rs b/packages/sequent-core/src/services/s3.rs index c3a8b9e0eb4..9f50d6a37fc 100644 --- a/packages/sequent-core/src/services/s3.rs +++ b/packages/sequent-core/src/services/s3.rs @@ -322,6 +322,17 @@ pub fn get_public_document_key( format!("tenant-{}/document-{}/{}", tenant_id, document_id, name) } +#[instrument(skip_all)] +/// Builds the public document key for an election event. +/// Used for when the UI does not have access to the document ID. +pub fn get_public_election_event_document_name_key( + tenant_id: &str, + election_event_id: &str, + name: &str, +) -> String { + format!("tenant-{}/event-{}/{}", tenant_id, election_event_id, name) +} + #[instrument(err)] /// Creates a presigned download URL for a document so clients can fetch files /// without proxying the bytes through the backend. diff --git a/packages/sequent-core/src/services/translations.rs b/packages/sequent-core/src/services/translations.rs index e5712043d6a..93b2eb4cf17 100644 --- a/packages/sequent-core/src/services/translations.rs +++ b/packages/sequent-core/src/services/translations.rs @@ -7,6 +7,7 @@ use crate::{ Contest, ContestEncryptionPolicy, ContestPresentation, DecodedBallotsInclusionPolicy, DelegatedVotingPolicy, ElectionEventPresentation, ElectionPresentation, I18nContent, + LanguageDetectionPolicy, }, serialization::deserialize_with_path::deserialize_value, types::hasura::core::{Election, ElectionEvent}, @@ -81,6 +82,13 @@ impl ElectionEvent { .and_then(|p| p.delegated_voting_policy) .unwrap_or_default() } + + pub fn get_language_detection_policy(&self) -> LanguageDetectionPolicy { + parse_presentation::(&self.presentation) + .and_then(|p| p.language_conf) + .and_then(|c| c.language_detection_policy) + .unwrap_or_default() + } } impl Name for ElectionEvent { diff --git a/packages/sequent-core/src/wasm/wasm.rs b/packages/sequent-core/src/wasm/wasm.rs index 23c50180e39..e793ddb12c4 100644 --- a/packages/sequent-core/src/wasm/wasm.rs +++ b/packages/sequent-core/src/wasm/wasm.rs @@ -1233,3 +1233,13 @@ pub fn get_default_consolidated_report_policy_js() -> Result { )) }) } + +#[wasm_bindgen] +pub fn get_default_language_detection_policy_js() -> Result { + let policy: LanguageDetectionPolicy = LanguageDetectionPolicy::default(); + serde_wasm_bindgen::to_value(&policy).map_err(|err| { + JsValue::from_str(&format!( + "Error serializing default language detection policy: {err}" + )) + }) +} diff --git a/packages/ui-core/package.json b/packages/ui-core/package.json index 855efdac9d7..51f2e476bd2 100644 --- a/packages/ui-core/package.json +++ b/packages/ui-core/package.json @@ -32,7 +32,8 @@ "i18next-browser-languagedetector": "^8.2.0", "moderndash": "4.0.0", "qrcode.react": "3.1.0", - "sanitize-html": "2.12.1" + "sanitize-html": "2.12.1", + "tldts": "^6.0.0" }, "peerDependencies": { "react": "19.1.1", diff --git a/packages/ui-core/rust/sequent-core-0.1.0.tgz b/packages/ui-core/rust/sequent-core-0.1.0.tgz index bc6c9361075..a4d5fa5d536 100644 Binary files a/packages/ui-core/rust/sequent-core-0.1.0.tgz and b/packages/ui-core/rust/sequent-core-0.1.0.tgz differ diff --git a/packages/ui-core/src/index.tsx b/packages/ui-core/src/index.tsx index 438dc068328..8b6293ae24e 100644 --- a/packages/ui-core/src/index.tsx +++ b/packages/ui-core/src/index.tsx @@ -9,6 +9,9 @@ export { getLanguages, initializeLanguages, overwriteTranslations, + applyLanguagePolicy, + applyPresentationLanguagePolicy, + USER_LANGUAGE_COOKIE_NAME, } from "./services/i18n" export {useForwardedRef} from "./utils/ref" export {sleep} from "./services/sleep" @@ -33,3 +36,4 @@ export * from "./services/wasm" export * from "./services/sanitizeFilename" export * from "./types/AreaPresentation" export * from "./services/candidatePresentation" +export * from "./utils/cookies" diff --git a/packages/ui-core/src/services/i18n.ts b/packages/ui-core/src/services/i18n.ts index b1bd059210c..da0836d1ce2 100644 --- a/packages/ui-core/src/services/i18n.ts +++ b/packages/ui-core/src/services/i18n.ts @@ -14,6 +14,10 @@ import galegoTranslation from "../translations/gl" import dutchTranslation from "../translations/nl" import basqueTranslation from "../translations/eu" import {IElectionEventPresentation} from "../types/ElectionEventPresentation" +import {ELanguageDetectionPolicy, ILanguageConf} from "@root/types/LanguageConf" +import {getValueFromCookie} from "@root/utils/cookies" + +export const USER_LANGUAGE_COOKIE_NAME = "USER_LANGUAGE" export const initializeLanguages = (externalTranslations: Resource, language?: string) => { const libTranslations: Resource = { @@ -88,6 +92,56 @@ export const initializeLanguages = (externalTranslations: Resource, language?: s export const getLanguages = (i18n: I18N) => Object.keys(i18n.services.resourceStore.data) +/// Applies language detection policy defined in language config, if any. +export const applyLanguagePolicy = (languageConf: ILanguageConf | undefined): boolean => { + if (!languageConf || !languageConf.language_detection_policy) { + return false + } + + const {language_detection_policy, default_language_code} = languageConf + + // If policy exists and equals FORCE_DEFAULT, force default language + if ( + language_detection_policy === ELanguageDetectionPolicy.FORCE_DEFAULT && + default_language_code + ) { + i18n.changeLanguage(default_language_code) + return true + } + + return false +} + +/// Applies language policy defined in election event presentation, if any +/// Url search param "lang" > user selected locale (saved in cookie) > language detection policy > browser settings +/// The Url search param "lang" is checked in i18n initialization. +export const applyPresentationLanguagePolicy = ( + presentation: IElectionEventPresentation | undefined +): boolean => { + if (!presentation?.language_conf) { + return false + } + + // If query param "lang" exists, skip applying presentation policy to allow manual override + if (typeof window !== "undefined") { + const params = new URLSearchParams(window.location.search) + if (params.get("lang")) { + return false + } + } + let cookieLang: string | undefined + cookieLang = getValueFromCookie(USER_LANGUAGE_COOKIE_NAME) + console.log("cookieLang::: ", cookieLang) + + if (cookieLang) { + console.log("inn") + i18n.changeLanguage(cookieLang) + return true + } + + return applyLanguagePolicy(presentation.language_conf) +} + export const overwriteTranslations = ( electionEventPresentation: IElectionEventPresentation | undefined, changeDefaultLanguage: boolean = true @@ -117,20 +171,11 @@ export const overwriteTranslations = ( i18n.addResourceBundle(lang, "translations", mergedResources, true, true) // Overwriting existing resource for language }) + console.log("changeDefaultLanguage:", changeDefaultLanguage) + if (changeDefaultLanguage) { - let languageConf = electionEventPresentation?.language_conf - let enabledLanguages = languageConf?.enabled_language_codes ?? ["en"] - let defaultLanguage = languageConf?.default_language_code - let currentLanguage = i18n.language - if ( - !!enabledLanguages && - !!defaultLanguage && - defaultLanguage !== currentLanguage && - enabledLanguages.includes(defaultLanguage) - ) { - i18n.changeLanguage(defaultLanguage) - hasChangedDefaultLanguage = true - } + // Apply language policy: skip if query param provided, otherwise check for FORCE_DEFAULT + hasChangedDefaultLanguage = applyPresentationLanguagePolicy(electionEventPresentation) } return hasChangedDefaultLanguage } diff --git a/packages/ui-core/src/services/wasm.ts b/packages/ui-core/src/services/wasm.ts index 0657a69ef53..035c9e65a01 100644 --- a/packages/ui-core/src/services/wasm.ts +++ b/packages/ui-core/src/services/wasm.ts @@ -10,6 +10,7 @@ import SequentCoreLibInit, { get_layout_properties_from_contest_js, set_hooks, get_default_consolidated_report_policy_js, + get_default_language_detection_policy_js, } from "sequent-core" import { sort_elections_list_js, @@ -55,6 +56,7 @@ import { EDuplicatedRankPolicy, EPreferenceGapsPolicy, EConsolidatedReportPolicy, + ELanguageDetectionPolicy, } from ".." export type { @@ -446,3 +448,12 @@ export const getDefaultConsolidatedReportPolicy = (): EConsolidatedReportPolicy throw error } } + +export const getDefaultLanguageDetectionPolicy = (): ELanguageDetectionPolicy => { + try { + return get_default_language_detection_policy_js() as ELanguageDetectionPolicy + } catch (error) { + console.log(error) + throw error + } +} diff --git a/packages/ui-core/src/types/LanguageConf.ts b/packages/ui-core/src/types/LanguageConf.ts index 5b34b91b2b4..035649517cb 100644 --- a/packages/ui-core/src/types/LanguageConf.ts +++ b/packages/ui-core/src/types/LanguageConf.ts @@ -2,7 +2,12 @@ // // SPDX-License-Identifier: AGPL-3.0-only +export enum ELanguageDetectionPolicy { + BROWSER_DETECT = "browser-detect", + FORCE_DEFAULT = "force-default", +} export interface ILanguageConf { enabled_language_codes?: Array default_language_code?: string + language_detection_policy?: ELanguageDetectionPolicy } diff --git a/packages/ui-core/src/utils/cookies.ts b/packages/ui-core/src/utils/cookies.ts new file mode 100644 index 00000000000..ee976728636 --- /dev/null +++ b/packages/ui-core/src/utils/cookies.ts @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only +import {getDomain} from "tldts" + +export function getValueFromCookie(cookieName: string) { + const cookies = Object.fromEntries(document.cookie.split("; ").map((c) => c.split("="))) + const value = cookies[cookieName] + + return value || undefined +} + +export function setCookie(name: string, value: string) { + // Extract the parent domain from the current hostname. + const hostname = window.location.hostname + const domain = getDomain(hostname) || "" + + let cookie = + `${encodeURIComponent(name)}=${encodeURIComponent(value)}` + `; Path=/` + `; SameSite=Lax` + + if (domain) { + cookie += `; Domain=${domain}` + } + + if (window.location.protocol === "https:") { + cookie += `; Secure` + } + + document.cookie = cookie +} diff --git a/packages/ui-essentials/src/components/Header/Header.tsx b/packages/ui-essentials/src/components/Header/Header.tsx index 9cebbe9dc25..62b4ad44a65 100644 --- a/packages/ui-essentials/src/components/Header/Header.tsx +++ b/packages/ui-essentials/src/components/Header/Header.tsx @@ -131,6 +131,7 @@ export interface HeaderProps { languagesList?: Array errorVariant?: HeaderErrorVariant expiry?: IExpiryCountdown + onChangeLanguage?: (lang: string) => void } export default function Header({ @@ -143,6 +144,7 @@ export default function Header({ languagesList, errorVariant, expiry = undefined, + onChangeLanguage, }: HeaderProps) { const {t} = useTranslation() const [openModal, setOpenModal] = useState(false) @@ -176,7 +178,10 @@ export default function Header({ > - + {errorVariant === HeaderErrorVariant.HIDE_PROFILE && !!logoutFn ? ( ( ` ) -const LanguageMenu: React.FC<{languagesList?: Array; label?: string}> = ({ - languagesList = ["en"], -}) => { +const LanguageMenu: React.FC<{ + languagesList?: Array + label?: string + onChange?: (lang: string) => void +}> = ({languagesList = ["en"], onChange}) => { const {t, i18n} = useTranslation() const [anchorEl, setAnchorEl] = React.useState(null) const open = Boolean(anchorEl) @@ -42,6 +44,9 @@ const LanguageMenu: React.FC<{languagesList?: Array; label?: string}> = const changeLanguage = async (lang: string) => { handleClose() await i18n.changeLanguage(lang) + if (onChange) { + onChange(lang) + } } return ( diff --git a/packages/voting-portal/rust/sequent-core-0.1.0.tgz b/packages/voting-portal/rust/sequent-core-0.1.0.tgz index bc6c9361075..a4d5fa5d536 100644 Binary files a/packages/voting-portal/rust/sequent-core-0.1.0.tgz and b/packages/voting-portal/rust/sequent-core-0.1.0.tgz differ diff --git a/packages/voting-portal/src/App.tsx b/packages/voting-portal/src/App.tsx index 5d9b9b6bb51..4ceee6d1623 100644 --- a/packages/voting-portal/src/App.tsx +++ b/packages/voting-portal/src/App.tsx @@ -2,11 +2,18 @@ // // SPDX-License-Identifier: AGPL-3.0-only -import React, {useEffect, useContext, useMemo} from "react" +import React, {useEffect, useContext, useMemo, useCallback} from "react" import {Outlet, ScrollRestoration, useLocation, useParams} from "react-router-dom" import {styled} from "@mui/material/styles" import {Footer, Header, PageBanner} from "@sequentech/ui-essentials" -import {EVotingPortalCountdownPolicy, IElectionEventPresentation} from "@sequentech/ui-core" +import { + ELanguageDetectionPolicy, + EVotingPortalCountdownPolicy, + IElectionEventPresentation, + USER_LANGUAGE_COOKIE_NAME, + setCookie, + getValueFromCookie, +} from "@sequentech/ui-core" import Stack from "@mui/material/Stack" import {useNavigate} from "react-router-dom" import {AuthContext} from "./providers/AuthContextProvider" @@ -15,7 +22,7 @@ import {TenantEventType} from "." import {ApolloWrapper} from "./providers/ApolloContextProvider" import {VotingPortalError, VotingPortalErrorType} from "./services/VotingPortalError" import {useAppSelector} from "./store/hooks" -import {selectElectionById, selectElectionIds} from "./store/elections/electionsSlice" +import {selectElectionIds} from "./store/elections/electionsSlice" import { selectBallotStyleByElectionId, selectBallotStyleElectionIds, @@ -25,7 +32,12 @@ import WatermarkBackground from "./components/WaterMark/Watermark" import SequentLogo from "@sequentech/ui-essentials/public/Sequent_logo.svg" import BlankLogoImg from "@sequentech/ui-essentials/public/blank_logo.svg" import {useElectionClassName} from "./hooks/useElectionClassName" - +interface ElectionEventConfigDocument { + id: string + tenant_id: string + election_event_id: string + election_event_presentation: IElectionEventPresentation +} const StyledApp = styled(Stack)` min-height: 100vh; @@ -79,6 +91,12 @@ const HeaderWithContext: React.FC = () => { ? SequentLogo : presentation?.logo_url + const onChangeLanguage = (lang: string) => { + if (getValueFromCookie(USER_LANGUAGE_COOKIE_NAME) !== lang) { + setCookie(USER_LANGUAGE_COOKIE_NAME, lang) + } + } + return (
    { endTime: authContext.getExpiry(), duration: countdownPolicy?.countdown_anticipation_secs, }} + onChangeLanguage={onChangeLanguage} /> ) } @@ -108,7 +127,7 @@ const App = () => { const {globalSettings} = useContext(SettingsContext) const location = useLocation() const {tenantId, eventId} = useParams() - const {isAuthenticated, setTenantEvent} = useContext(AuthContext) + const {isAuthenticated, setTenantEvent, setDefaultLocale} = useContext(AuthContext) const electionIds = useAppSelector(selectElectionIds) const ballotStyleElectionIds = useAppSelector(selectBallotStyleElectionIds) @@ -133,23 +152,62 @@ const App = () => { location.pathname, ]) + const electionEventConfigUrl = `${globalSettings.PUBLIC_BUCKET_URL}tenant-${tenantId}/event-${eventId}/election_event_config.json` + + // Set up tenant and event in AuthContext on initial load. + // It is needed to fetch the election event config file from S3 + // and apply the language policy before loading any other data. + const setupTenantEvent = useCallback(async () => { + if (!tenantId || !eventId) { + return + } + + const isRegisterFlow = location.pathname.includes("/enroll") + const mode = isRegisterFlow ? "register" : "login" + + try { + const response = await fetch(electionEventConfigUrl) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + + const config = (await response.json()) as ElectionEventConfigDocument + const presentation = config.election_event_presentation + const languageConf = presentation?.language_conf + + if ( + languageConf && + languageConf.language_detection_policy === ELanguageDetectionPolicy.FORCE_DEFAULT + ) { + setDefaultLocale(languageConf.default_language_code) + } + setTenantEvent(tenantId, eventId, mode) + } catch (error) { + console.error("Error loading election event config:", error) + setTenantEvent(tenantId, eventId, mode) + } + }, [tenantId, eventId, electionEventConfigUrl, setTenantEvent, setDefaultLocale]) + useEffect(() => { + if (isAuthenticated) { + return + } + const isDemo = sessionStorage.getItem("isDemo") - if (!isAuthenticated && !globalSettings.DISABLE_AUTH && isDemo) { + if (!globalSettings.DISABLE_AUTH && isDemo) { const areaId = sessionStorage.getItem("areaId") const documentId = sessionStorage.getItem("documentId") const publicationId = sessionStorage.getItem("publicationId") + navigate(`/preview/${tenantId}/${documentId}/${areaId}/${publicationId}`) window.location.reload() - } else if (!isAuthenticated && !!tenantId && !!eventId) { - setTenantEvent( - tenantId, - eventId, - location.pathname.includes("/enroll") ? "register" : "login" - ) + return } - }, [tenantId, eventId, isAuthenticated, setTenantEvent, globalSettings.DISABLE_AUTH]) + + void setupTenantEvent() + }, [isAuthenticated, globalSettings.DISABLE_AUTH, navigate, tenantId, setupTenantEvent]) return ( -// -// SPDX-License-Identifier: AGPL-3.0-only - -import React, {useEffect} from "react" -import {useTranslation} from "react-i18next" -import {IBallotStyle} from "../store/ballotStyles/ballotStylesSlice" - -type props = { - ballotStyle: IBallotStyle | undefined -} -const useLanguage = ({ballotStyle}: props) => { - const {i18n} = useTranslation() - - useEffect(() => { - const currLanguage = i18n.language - const electionLanguages = - ballotStyle?.ballot_eml?.election_presentation?.language_conf?.enabled_language_codes - const defaultLang = - ballotStyle?.ballot_eml?.election_presentation?.language_conf?.default_language_code - if ( - !electionLanguages || - !currLanguage || - electionLanguages.includes(currLanguage) || - !defaultLang - ) - return - i18n.changeLanguage(defaultLang) - }, []) -} - -export default useLanguage diff --git a/packages/voting-portal/src/hooks/useUpdateTranslation.ts b/packages/voting-portal/src/hooks/useUpdateTranslation.ts index 27a21f8f733..bc2e343e847 100644 --- a/packages/voting-portal/src/hooks/useUpdateTranslation.ts +++ b/packages/voting-portal/src/hooks/useUpdateTranslation.ts @@ -15,6 +15,9 @@ const useUpdateTranslation = ( setDefaultLanguageTouched: (value: boolean) => void ) => { // Overwrites translations based on the election event presentation + // Update Language based on presentation only if default language has not been touched, + // So search param "lang" > user selected locale (saved in cookie) > + // language detection policy (force default) > browser settings useEffect(() => { if (!electionEvent?.presentation) { return diff --git a/packages/voting-portal/src/providers/AuthContextProvider.tsx b/packages/voting-portal/src/providers/AuthContextProvider.tsx index ecff6223a97..70001ca604f 100644 --- a/packages/voting-portal/src/providers/AuthContextProvider.tsx +++ b/packages/voting-portal/src/providers/AuthContextProvider.tsx @@ -5,7 +5,7 @@ import React, {useContext} from "react" import Keycloak, {KeycloakConfig, KeycloakInitOptions} from "keycloak-js" import {createContext, useEffect, useState} from "react" -import {sleep} from "@sequentech/ui-core" +import {getValueFromCookie, sleep, USER_LANGUAGE_COOKIE_NAME} from "@sequentech/ui-core" import {SettingsContext} from "./SettingsContextProvider" import {getLanguageFromURL} from "../utils/queryParams" import {useTranslation} from "react-i18next" @@ -73,6 +73,8 @@ export interface AuthContextValues { isGoldUser: () => boolean reauthWithGold: (redirectUri: string) => Promise + + setDefaultLocale: (locale?: string) => void } interface UserProfile { @@ -101,6 +103,7 @@ const defaultAuthContextValues: AuthContextValues = { openProfileLink: () => new Promise(() => undefined), isGoldUser: () => false, reauthWithGold: async () => {}, + setDefaultLocale: () => {}, } /** @@ -138,6 +141,7 @@ const AuthContextProvider = (props: AuthContextProviderProps) => { const [tenantId, setTenantId] = useState(null) const [eventId, setEventId] = useState(null) const [authType, setAuthType] = useState<"register" | "login" | null>(null) + const [defaultLocale, setDefaultLocale] = useState(undefined) const {i18n} = useTranslation() @@ -274,7 +278,10 @@ const AuthContextProvider = (props: AuthContextProviderProps) => { // opening the app or reloading the page). If not authenticated the user will // be send to the login form. If already authenticated the webapp will open. checkLoginIframe: false, - locale: getLanguageFromURL(), + locale: + getLanguageFromURL() || + getValueFromCookie(USER_LANGUAGE_COOKIE_NAME) || + defaultLocale, } const isAuthenticatedResponse = await keycloak.init(keycloakInitOptions) @@ -353,6 +360,7 @@ const AuthContextProvider = (props: AuthContextProviderProps) => { try { const profile = await keycloak.loadUserProfile() + setUserProfile((val) => ({ ...val, userId: profile?.id || val?.userId, @@ -480,6 +488,7 @@ const AuthContextProvider = (props: AuthContextProviderProps) => { keycloakAccessToken, isGoldUser, reauthWithGold, + setDefaultLocale, }} > {props.children} diff --git a/packages/voting-portal/src/routes/BallotLocator.tsx b/packages/voting-portal/src/routes/BallotLocator.tsx index 2c9116efb32..3ef389de8bd 100644 --- a/packages/voting-portal/src/routes/BallotLocator.tsx +++ b/packages/voting-portal/src/routes/BallotLocator.tsx @@ -34,7 +34,6 @@ import {LIST_CAST_VOTE_MESSAGES} from "../queries/listCastVoteMessages" import {updateBallotStyleAndSelection} from "../services/BallotStyles" import {useAppDispatch, useAppSelector} from "../store/hooks" import {selectFirstBallotStyle} from "../store/ballotStyles/ballotStylesSlice" -import useLanguage from "../hooks/useLanguage" import {SettingsContext} from "../providers/SettingsContextProvider" import useUpdateTranslation from "../hooks/useUpdateTranslation" import {GET_ELECTION_EVENT} from "../queries/GetElectionEvent" @@ -673,7 +672,6 @@ const BallotLocatorLogic = () => { const dispatch = useAppDispatch() const ballotStyle = useAppSelector(selectFirstBallotStyle) - useLanguage({ballotStyle}) const {data, loading} = useQuery(GET_CAST_VOTE, { variables: { diff --git a/packages/voting-portal/src/routes/ElectionSelectionScreen.tsx b/packages/voting-portal/src/routes/ElectionSelectionScreen.tsx index 05588d5ca36..070aa3d3255 100644 --- a/packages/voting-portal/src/routes/ElectionSelectionScreen.tsx +++ b/packages/voting-portal/src/routes/ElectionSelectionScreen.tsx @@ -277,6 +277,7 @@ const ElectionSelectionScreen: React.FC = () => { const {eventId, tenantId} = useParams<{eventId?: string; tenantId?: string}>() const electionEvent = useAppSelector(selectElectionEventById(eventId)) const oneBallotStyle = useAppSelector(selectFirstBallotStyle) + //Handle both transalations from presentation and i18n language change. useUpdateTranslation({electionEvent}, defaultLanguageTouched, setDefaultLanguageTouched) // Overwrite translations const ballotStyleElectionIds = useAppSelector(selectBallotStyleElectionIds) const electionIds = useAppSelector(selectElectionIds) diff --git a/packages/voting-portal/src/routes/StartScreen.tsx b/packages/voting-portal/src/routes/StartScreen.tsx index 009dac10cfd..893d55c1f7a 100644 --- a/packages/voting-portal/src/routes/StartScreen.tsx +++ b/packages/voting-portal/src/routes/StartScreen.tsx @@ -22,7 +22,6 @@ import {TenantEventType} from ".." import {useRootBackLink} from "../hooks/root-back-link" import Stepper from "../components/Stepper" import {selectBallotStyleByElectionId, showDemo} from "../store/ballotStyles/ballotStylesSlice" -import useLanguage from "../hooks/useLanguage" import {selectElectionEventById} from "../store/electionEvents/electionEventsSlice" import {resetBallotSelection} from "../store/ballotSelections/ballotSelectionsSlice" import {clearIsVoted} from "../store/extra/extraSlice" @@ -157,7 +156,6 @@ const StartScreen: React.FC = () => { const [showDemoDialog, setShowDemoDialog] = useState(isDemo) const dispatch = useAppDispatch() const navigate = useNavigate() - useLanguage({ballotStyle}) const titleObject = useMemo(() => { const startScreenTitlePolicy = election?.presentation?.start_screen_title_policy diff --git a/packages/voting-portal/src/routes/VotingScreen.tsx b/packages/voting-portal/src/routes/VotingScreen.tsx index 167c0eb474c..36528d3fdb3 100644 --- a/packages/voting-portal/src/routes/VotingScreen.tsx +++ b/packages/voting-portal/src/routes/VotingScreen.tsx @@ -582,6 +582,8 @@ export async function action({request}: {request: Request}) { VotingPortalErrorType[error as keyof typeof VotingPortalErrorType] ) } + const url = new URL(request.url) + const search = url.search || "" - return redirect(`../review`) + return redirect(`../review${search}`) } diff --git a/packages/windmill/src/services/ballot_styles/ballot_style.rs b/packages/windmill/src/services/ballot_styles/ballot_style.rs index f830e9547ac..1ded9873c5b 100644 --- a/packages/windmill/src/services/ballot_styles/ballot_style.rs +++ b/packages/windmill/src/services/ballot_styles/ballot_style.rs @@ -15,6 +15,7 @@ use crate::postgres::election_event::get_election_event_by_id; use crate::postgres::keys_ceremony::get_keys_ceremonies; use crate::postgres::scheduled_event::find_scheduled_event_by_election_event_id; use crate::services::database::get_hasura_pool; +use crate::services::documents::upload_and_return_public_event_document; use crate::services::election_dates::get_election_dates; use crate::types::error::{Error, Result}; use anyhow::{anyhow, Context, Result as AnyhowResult}; @@ -22,13 +23,16 @@ use chrono::Duration; use deadpool_postgres::{Client as DbClient, Transaction}; use futures::try_join; use rocket::http::Status; +use sequent_core::ballot::ElectionEventPresentation; use sequent_core::types::hasura::core::{ self as hasura_type, Area, AreaContest, BallotPublication, BallotStyle, Candidate, Contest, Election, ElectionEvent, KeysCeremony, }; use sequent_core::types::scheduled_event::ScheduledEvent; +use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; +use std::io::Write; use tracing::{event, instrument, Level}; use uuid::Uuid; @@ -37,6 +41,16 @@ use sequent_core::services::date::ISO8601; use sequent_core::services::area_tree::TreeNode; +pub const EVENT_CONFIG_FILE_NAME: &str = "election_event_config.json"; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ElectionEventConfig { + pub id: String, + pub election_event_id: String, + pub tenant_id: String, + pub election_event_presentation: ElectionEventPresentation, +} + /** * Returns a HashMap> with all * the election ids and contest ids related to an area, @@ -184,6 +198,49 @@ pub async fn create_ballot_style_postgres( Ok(()) } +/// Creates a JSON file with the election event config with presentation data +/// and uploads it to S3 public bucket. +pub async fn create_public_election_event_config_file( + hasura_transaction: &Transaction<'_>, + tenant_id: &str, + election_event: &ElectionEvent, +) -> AnyhowResult<()> { + let event_presentation = election_event.get_presentation()?; + if let Some(presentation) = event_presentation { + let id = Uuid::new_v4().to_string(); + + let config_data = ElectionEventConfig { + id: id.clone(), + tenant_id: tenant_id.to_string(), + election_event_id: election_event.id.clone(), + election_event_presentation: presentation, + }; + + let config_json = serde_json::to_string(&config_data)?; + // Write to temp file + let mut temp_file = tempfile::NamedTempFile::new()?; + temp_file.write_all(config_json.as_bytes())?; + + let temp_file_path = temp_file.path().to_string_lossy().to_string(); + let file_size = config_json.len() as u64; + + // Upload to S3 public bucket with election_event_id in path + let _document = upload_and_return_public_event_document( + hasura_transaction, + &temp_file_path, + file_size, + "application/json", + tenant_id, + election_event.id.as_str(), + EVENT_CONFIG_FILE_NAME, + Some(id), + ) + .await?; + } + + Ok(()) +} + #[instrument(err)] pub async fn update_election_event_ballot_styles( tenant_id: &str, @@ -292,6 +349,8 @@ pub async fn update_election_event_ballot_styles( ) .await?; + create_public_election_event_config_file(&transaction, tenant_id, &election_event).await?; + let _commit = transaction.commit().await.with_context(|| "Commit failed"); lock.release().await?; Ok(()) diff --git a/packages/windmill/src/services/delete_election_event.rs b/packages/windmill/src/services/delete_election_event.rs index 01b6d6af342..587d4eb6a30 100644 --- a/packages/windmill/src/services/delete_election_event.rs +++ b/packages/windmill/src/services/delete_election_event.rs @@ -116,8 +116,14 @@ pub async fn delete_election_event_related_documents( ) -> Result<()> { let documents_prefix = format!("tenant-{}/event-{}/", tenant_id, election_event_id); let bucket = s3::get_private_bucket()?; - s3::delete_files_from_s3(bucket, documents_prefix, false) + s3::delete_files_from_s3(bucket, documents_prefix.clone(), false) .await .map_err(|err| anyhow!("Error delete private files from s3: {err:?}"))?; + + // Also delete the public files related to the election event, such as the election event config + let public_bucket = s3::get_public_bucket()?; + s3::delete_files_from_s3(public_bucket, documents_prefix, false) + .await + .map_err(|err| anyhow!("Error delete public files from s3: {err:?}"))?; Ok(()) } diff --git a/packages/windmill/src/services/documents.rs b/packages/windmill/src/services/documents.rs index 31b02220967..d650fdb0ff2 100644 --- a/packages/windmill/src/services/documents.rs +++ b/packages/windmill/src/services/documents.rs @@ -75,6 +75,53 @@ pub async fn upload_and_return_document( Ok(document) } +/// Uploads a document to S3 public bucket and returns the created Document record. +/// The document is associated with the given election event ID and tenant ID. +/// The Document path does not include the document ID and will be used +/// for when the UI does not have access to the document ID. +#[instrument(skip(hasura_transaction), err)] +pub async fn upload_and_return_public_event_document( + hasura_transaction: &Transaction<'_>, + file_path: &str, + file_size: u64, + media_type: &str, + tenant_id: &str, + election_event_id: &str, + name: &str, + document_id: Option, +) -> AnyhowResult { + let document = insert_document( + hasura_transaction, + tenant_id, + Some(election_event_id.to_string()), + name, + media_type, + file_size.try_into()?, + true, + document_id, + ) + .await?; + + info!("Document inserted {document:?}"); + let document_s3_key = + s3::get_public_election_event_document_name_key(tenant_id, election_event_id, name); + let bucket = s3::get_public_bucket()?; + + s3::upload_file_to_s3( + /* key */ document_s3_key, + /* is_public: always false because it's windmill that uploads the file */ false, + /* s3_bucket */ bucket, + /* media_type */ media_type.to_string(), + /* file_path */ file_path.to_string(), + /* cache_control_policy */ None, + Some(name.to_string()), + ) + .await + .with_context(|| "Failed uploading file to s3")?; + + Ok(document) +} + #[instrument(skip(hasura_transaction), err)] pub async fn get_upload_url( hasura_transaction: &Transaction<'_>, diff --git a/packages/windmill/src/services/export/export_ballot_publication.rs b/packages/windmill/src/services/export/export_ballot_publication.rs index f1a9c1fbfaf..04eff4e9ea1 100644 --- a/packages/windmill/src/services/export/export_ballot_publication.rs +++ b/packages/windmill/src/services/export/export_ballot_publication.rs @@ -3,11 +3,15 @@ // SPDX-License-Identifier: AGPL-3.0-only use crate::postgres::ballot_publication::get_ballot_publication; use crate::postgres::ballot_style::export_event_ballot_styles; +use crate::services::ballot_styles::ballot_style::EVENT_CONFIG_FILE_NAME; use crate::services::documents::upload_and_return_document; use anyhow::{anyhow, Context, Result}; use csv::Writer; use deadpool_postgres::{Client as DbClient, Transaction}; use sequent_core::serialization::deserialize_with_path::deserialize_str; +use sequent_core::services::s3::{ + get_object_into_temp_file, get_public_bucket, get_public_election_event_document_name_key, +}; use sequent_core::types::hasura::core::Document; use sequent_core::types::hasura::core::{BallotPublication, Template}; use sequent_core::util::temp_path::write_into_named_temp_file; @@ -138,3 +142,28 @@ pub async fn export_ballot_publications( Ok(temp_path) } + +/// Exports election event config file which created at ballot publication generation. +// #[instrument(err)] +pub async fn export_election_event_config_file( + tenant_id: &str, + election_event_id: &str, +) -> Result { + let s3_bucket = get_public_bucket()?; + + let document_name = EVENT_CONFIG_FILE_NAME; + + // Obtain the key for the document in S3 + let document_s3_key = + get_public_election_event_document_name_key(tenant_id, election_event_id, document_name); + + let file = get_object_into_temp_file( + s3_bucket.as_str(), + document_s3_key.as_str(), + &document_name, + ".tmp", + ) + .await + .with_context(|| "Failed to get S3 object into temporary file")?; + Ok(file.into_temp_path()) +} diff --git a/packages/windmill/src/services/export/export_election_event.rs b/packages/windmill/src/services/export/export_election_event.rs index 3cda1b3944a..71d90e4fee8 100644 --- a/packages/windmill/src/services/export/export_election_event.rs +++ b/packages/windmill/src/services/export/export_election_event.rs @@ -14,7 +14,7 @@ use crate::postgres::keys_ceremony::get_keys_ceremonies; use crate::postgres::reports::get_reports_by_election_event_id; use crate::postgres::trustee::get_all_trustees; use crate::services::database::get_hasura_pool; -use crate::services::export::export_ballot_publication; +use crate::services::export::export_ballot_publication::{self, export_election_event_config_file}; use crate::services::import::import_election_event::ImportElectionEventSchema; use crate::services::reports::activity_log; use crate::services::reports::activity_log::{ActivityLogsTemplate, ReportFormat}; @@ -630,6 +630,26 @@ pub async fn process_export_zip( .map_err(|e| anyhow!("Error opening temporary ballot publications file: {e:?}"))?; std::io::copy(&mut ballot_publication_file, &mut zip_writer) .map_err(|e| anyhow!("Error copying ballot publications file to ZIP: {e:?}"))?; + + // Handle election event config file (which is created in ballot publication) + let election_event_config = format!( + "{}-{}.json", + EDocuments::ELECTION_EVENT_CONFIG.to_file_name(), + election_event_id + ); + + zip_writer + .start_file(&election_event_config, options) + .map_err(|e| anyhow!("Error starting election event config file in ZIP: {e:?}"))?; + let election_event_config_temp_path = + export_election_event_config_file(tenant_id, election_event_id) + .await + .map_err(|err| anyhow!("Error exporting election event config file: {err}"))?; + + let mut election_event_config_file = File::open(election_event_config_temp_path) + .map_err(|e| anyhow!("Error opening temporary election event config file: {e:?}"))?; + std::io::copy(&mut election_event_config_file, &mut zip_writer) + .map_err(|e| anyhow!("Error copying election event config file to ZIP: {e:?}"))?; } // add protocol manager secrets diff --git a/packages/windmill/src/services/import/import_election_event.rs b/packages/windmill/src/services/import/import_election_event.rs index 2a983e37d12..5fefca3a971 100644 --- a/packages/windmill/src/services/import/import_election_event.rs +++ b/packages/windmill/src/services/import/import_election_event.rs @@ -7,7 +7,9 @@ use crate::postgres::election_event::{get_election_event_by_id_if_exist, update_ use crate::postgres::reports::insert_reports; use crate::postgres::reports::Report; use crate::postgres::trustee::get_all_trustees; -use crate::services::import::import_publications::import_ballot_publications; +use crate::services::import::import_publications::{ + import_ballot_publications, import_election_event_config_file, +}; use crate::services::import::import_scheduled_events::import_scheduled_events; use crate::services::import::import_tally::process_tally_file; use crate::services::protocol_manager::get_event_board; @@ -22,8 +24,8 @@ use chrono::format; use chrono::{DateTime, Utc}; use deadpool_postgres::{Client as DbClient, Transaction}; use futures::future::try_join_all; +use keycloak::types::RealmEventsConfigRepresentation; use once_cell::sync::Lazy; -use sequent_core::ballot::AllowTallyStatus; use sequent_core::ballot::ElectionEventStatistics; use sequent_core::ballot::ElectionEventStatus; use sequent_core::ballot::ElectionStatistics; @@ -31,6 +33,7 @@ use sequent_core::ballot::ElectionStatus; use sequent_core::ballot::PeriodDates; use sequent_core::ballot::VotingPeriodDates; use sequent_core::ballot::VotingStatus; +use sequent_core::ballot::{AllowTallyStatus, LanguageDetectionPolicy}; use sequent_core::serialization::deserialize_with_path::deserialize_str; use sequent_core::serialization::deserialize_with_path::deserialize_value; use sequent_core::services::connection; @@ -48,7 +51,7 @@ use sequent_core::util::version::{ check_version_compatibility, DEV_APP_VERSION, ENV_VAR_APP_VERSION, }; use serde::{Deserialize, Serialize}; -use serde_json::{json, Map, Value}; +use serde_json::{de, json, Map, Value}; use std::collections::HashMap; use std::env; use std::fs; @@ -273,6 +276,7 @@ pub async fn upsert_keycloak_realm( tenant_id: &str, election_event_id: &str, keycloak_event_realm: Option, + default_locale: Option, ) -> Result<()> { let mut realm = if let Some(realm) = keycloak_event_realm.clone() { realm @@ -280,6 +284,11 @@ pub async fn upsert_keycloak_realm( let realm = read_default_election_event_realm()?; realm }; + + if let Some(default_language) = default_locale { + realm.default_locale = Some(default_language.clone()); + } + realm = remove_keycloak_realm_secrets(&realm)?; let realm_config = serde_json::to_string(&realm)?; let client = KeycloakAdminClient::new().await?; @@ -555,10 +564,18 @@ pub async fn process_election_event_file( .collect::>>() .with_context(|| "Error processing elections")?; + let language_detection_policy = data.election_event.get_language_detection_policy(); + let mut default_language = None; + println!("Language detection policy: {language_detection_policy:?}"); + if language_detection_policy == LanguageDetectionPolicy::FORCE_DEFAULT { + default_language = Some(data.election_event.get_default_language()); + } + upsert_keycloak_realm( tenant_id.as_str(), &election_event_id, data.keycloak_event_realm.clone(), + default_language ) .await .with_context(|| format!("Error upserting Keycloak realm for tenant ID {tenant_id} and election event ID {election_event_id}"))?; @@ -1229,6 +1246,28 @@ pub async fn process_document( .await .with_context(|| "Error importing publications")?; } + if file_name.contains(&format!( + "{}", + EDocuments::ELECTION_EVENT_CONFIG.to_file_name() + )) { + let mut temp_file = NamedTempFile::new() + .context("Failed to create election event config temporary file")?; + + io::copy(&mut cursor, &mut temp_file).context( + "Failed to copy contents of election event config file to temporary file", + )?; + temp_file.as_file_mut().rewind()?; + + import_election_event_config_file( + hasura_transaction, + &election_event_schema.tenant_id.to_string(), + &election_event_schema.election_event.id, + temp_file, + replacement_map.clone(), + ) + .await + .with_context(|| "Error importing election event config file")?; + } if file_name.contains(&format!( "{}", diff --git a/packages/windmill/src/services/import/import_publications.rs b/packages/windmill/src/services/import/import_publications.rs index 84c26afad70..a5f6529d225 100644 --- a/packages/windmill/src/services/import/import_publications.rs +++ b/packages/windmill/src/services/import/import_publications.rs @@ -14,10 +14,14 @@ use serde_json::Value as JsonValue; use std::collections::HashMap; use std::fs::File; use std::io::Read; +use std::io::Write; use tempfile::NamedTempFile; use tracing::{info, instrument}; use uuid::Uuid; +use crate::services::ballot_styles::ballot_style::{ElectionEventConfig, EVENT_CONFIG_FILE_NAME}; +use crate::services::documents::upload_and_return_public_event_document; + #[derive(Serialize, Deserialize, Debug, Clone)] pub struct ballot_design { ballot_publication_id: String, @@ -40,3 +44,52 @@ pub async fn import_ballot_publications( //TODO: implement import Ok(()) } + +/// Imports the election event config file, +/// This file contains the election event presentation and is created during publication. +#[instrument(err, skip(replacement_map))] +pub async fn import_election_event_config_file( + hasura_transaction: &Transaction<'_>, + tenant_id: &str, + election_event_id: &str, + temp_file: NamedTempFile, + replacement_map: HashMap, +) -> Result<()> { + let mut file = File::open(temp_file)?; + let mut data_str = String::new(); + file.read_to_string(&mut data_str)?; + let original_data: ElectionEventConfig = deserialize_str(&data_str)?; + + let new_id = Uuid::new_v4(); + + let new_election_event_config = ElectionEventConfig { + id: new_id.to_string(), + tenant_id: tenant_id.to_string(), + election_event_id: election_event_id.to_string(), + election_event_presentation: original_data.election_event_presentation, + }; + + let config_json = serde_json::to_string(&new_election_event_config)?; + + // Write to temp file + let mut temp_file = NamedTempFile::new()?; + temp_file.write_all(config_json.as_bytes())?; + + let temp_file_path = temp_file.path().to_string_lossy().to_string(); + let file_size = config_json.len() as u64; + + // Upload to S3 public bucket with election_event_id in path + let _document = upload_and_return_public_event_document( + hasura_transaction, + &temp_file_path, + file_size, + "application/json", + tenant_id, + election_event_id, + EVENT_CONFIG_FILE_NAME, + Some(new_id.to_string()), + ) + .await?; + + Ok(()) +} diff --git a/packages/windmill/src/tasks/insert_election_event.rs b/packages/windmill/src/tasks/insert_election_event.rs index fd38645b668..470515ea907 100644 --- a/packages/windmill/src/tasks/insert_election_event.rs +++ b/packages/windmill/src/tasks/insert_election_event.rs @@ -57,7 +57,7 @@ pub async fn insert_election_event_anyhow( final_object.voting_channels = serde_json::to_value(VotingChannels::default()).ok(); } - match upsert_keycloak_realm(tenant_id.as_str(), &id.as_ref(), None).await { + match upsert_keycloak_realm(tenant_id.as_str(), &id.as_ref(), None, None).await { Ok(realm) => Some(realm), Err(err) => { update_fail( diff --git a/packages/windmill/src/types/documents.rs b/packages/windmill/src/types/documents.rs index 77eec015188..def63dc60f7 100644 --- a/packages/windmill/src/types/documents.rs +++ b/packages/windmill/src/types/documents.rs @@ -21,6 +21,7 @@ pub enum EDocuments { PUBLICATIONS, TALLY, IMAGES, + ELECTION_EVENT_CONFIG, } impl EDocuments { @@ -41,6 +42,7 @@ impl EDocuments { EDocuments::PUBLICATIONS => "export_publications", EDocuments::TALLY => "export_tally_data", EDocuments::IMAGES => "images", + EDocuments::ELECTION_EVENT_CONFIG => "election_event_config", } } } diff --git a/packages/yarn.lock b/packages/yarn.lock index 9c9287ab1ab..7106af3fd22 100644 --- a/packages/yarn.lock +++ b/packages/yarn.lock @@ -17488,15 +17488,15 @@ sentence-case@^3.0.4: "sequent-core@file:./admin-portal/rust/sequent-core-0.1.0.tgz": version "0.1.0" - resolved "file:./admin-portal/rust/sequent-core-0.1.0.tgz#64424a92cfba20e40ce57c9748859f67f2d17450" + resolved "file:./admin-portal/rust/sequent-core-0.1.0.tgz#f6ccb089f0532571b47fda7a78b359ff66cdcf3b" "sequent-core@file:./ballot-verifier/rust/sequent-core-0.1.0.tgz": version "0.1.0" - resolved "file:./ballot-verifier/rust/sequent-core-0.1.0.tgz#64424a92cfba20e40ce57c9748859f67f2d17450" + resolved "file:./ballot-verifier/rust/sequent-core-0.1.0.tgz#f6ccb089f0532571b47fda7a78b359ff66cdcf3b" "sequent-core@file:./voting-portal/rust/sequent-core-0.1.0.tgz": version "0.1.0" - resolved "file:./voting-portal/rust/sequent-core-0.1.0.tgz#64424a92cfba20e40ce57c9748859f67f2d17450" + resolved "file:./voting-portal/rust/sequent-core-0.1.0.tgz#f6ccb089f0532571b47fda7a78b359ff66cdcf3b" serialize-javascript@^6.0.0, serialize-javascript@^6.0.2: version "6.0.2" @@ -18528,6 +18528,18 @@ title-case@^3.0.3: dependencies: tslib "^2.0.3" +tldts-core@^6.1.86: + version "6.1.86" + resolved "https://registry.yarnpkg.com/tldts-core/-/tldts-core-6.1.86.tgz#a93e6ed9d505cb54c542ce43feb14c73913265d8" + integrity sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA== + +tldts@^6.0.0: + version "6.1.86" + resolved "https://registry.yarnpkg.com/tldts/-/tldts-6.1.86.tgz#087e0555b31b9725ee48ca7e77edc56115cd82f7" + integrity sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ== + dependencies: + tldts-core "^6.1.86" + tmp@^0.2.3: version "0.2.5" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.5.tgz#b06bcd23f0f3c8357b426891726d16015abfd8f8"