From 1d3732edcf45b92a66aaa21f6a78cf05a55f95a5 Mon Sep 17 00:00:00 2001 From: Emmanuel Jones Date: Sun, 6 Sep 2026 16:44:05 -0600 Subject: [PATCH] Add native guest ballot creation --- apps/mobile/README.md | 33 +- apps/mobile/app.json | 1 + apps/mobile/package-lock.json | 10 + apps/mobile/package.json | 1 + apps/mobile/scripts/android-phase1-e2e.mjs | 41 ++- apps/mobile/src/api/v2-api.test.ts | 83 +++++ apps/mobile/src/api/v2-api.ts | 88 +++++ apps/mobile/src/app/_layout.tsx | 1 + apps/mobile/src/app/create.tsx | 330 ++++++++++++++++++ apps/mobile/src/app/index.tsx | 35 +- apps/mobile/src/features/guest-ballot.test.ts | 49 +++ apps/mobile/src/features/guest-ballot.ts | 66 ++++ .../ballot-management-token-store.test.ts | 45 +++ .../utils/ballot-management-token-store.ts | 28 ++ docs/expo-migration-rfc.md | 5 + 15 files changed, 811 insertions(+), 5 deletions(-) create mode 100644 apps/mobile/src/app/create.tsx create mode 100644 apps/mobile/src/features/guest-ballot.test.ts create mode 100644 apps/mobile/src/features/guest-ballot.ts create mode 100644 apps/mobile/src/utils/ballot-management-token-store.test.ts create mode 100644 apps/mobile/src/utils/ballot-management-token-store.ts diff --git a/apps/mobile/README.md b/apps/mobile/README.md index 85bcf09..9dac40f 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -1,6 +1,6 @@ # Ranked Choices mobile -Phase 1 of the Ranked Choices Expo migration. This app currently provides a +Phase 2 of the Ranked Choices Expo migration is in progress. This app currently provides a shortcode lookup, ballot preview, and local candidate-ranking controls backed by the existing PHP API. Anonymous ballots can be submitted through the typed, idempotent v2 vote endpoint. Released votes are loaded through the public v2 @@ -9,7 +9,11 @@ TypeScript module. Secure ballots can be submitted with an assigned voter code. Ballots with voter grouping enabled render and validate their select, checkbox, and text questions before submission. Ballot and results screens can open the system share sheet with the canonical RankedChoices.com ballot link. -The app does not authenticate users yet. +The native app can also create a basic guest ballot from a name and candidate +list. The API generates its shortcode and a one-time management credential; +only the credential digest is stored on the server, while the native client +protects the credential with Expo SecureStore. The app does not authenticate +users yet, and advanced ballot creation remains on RankedChoices.com. ## Get started @@ -55,6 +59,14 @@ committing the eventual store identity: APP_VARIANT=development npx expo run:android ``` +Basic ballot creation uses a native SecureStore module and is disabled on Expo +web. Rebuild a development client after adding or updating that dependency. If +encrypted storage is unavailable, the client refuses to create a ballot. If +storage fails after the server responds, keep the success screen open and retry +saving access; the app retains the credential only in memory during that +recovery state and never shows it in a URL, share payload, log, or error +message. + The dynamic app config defaults local commands to the development variant. `RCV_ANDROID_PACKAGE` and `RCV_IOS_BUNDLE_IDENTIFIER` remain available as explicit local overrides. @@ -103,6 +115,21 @@ ADB="$ANDROID_HOME/platform-tools/adb" \ npm run test:android:e2e ``` +To create a disposable basic ballot, verify that its management credential was +saved, and open the new ballot in a development build: + +```bash +RCV_E2E_CREATE_BALLOT=1 \ +RCV_E2E_APP_PACKAGE=com.rankedchoices.dev \ +RCV_E2E_INCOMING_URL=rankedchoices:///create \ +RCV_E2E_COLD_START=0 \ +ADB="$ANDROID_HOME/platform-tools/adb" \ +npm run test:android:e2e +``` + +This scenario creates a real local database row. Remove the generated +shortcode from the disposable development database after the test. + Expo Go is the default target. A development build can exercise the custom scheme with: @@ -181,6 +208,8 @@ request, or user data. - accessible select, checkbox, and text grouping questions with client and server validation - canonical ballot-link sharing through the native system share sheet +- basic guest ballot creation with server-generated shortcodes and encrypted, + device-local management credentials - local winner and round-by-round result rendering after an accepted vote - loading, closed, not-found, malformed-response, and network-error handling diff --git a/apps/mobile/app.json b/apps/mobile/app.json index cf536d4..67b2acb 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -25,6 +25,7 @@ }, "plugins": [ "expo-router", + "expo-secure-store", [ "expo-splash-screen", { diff --git a/apps/mobile/package-lock.json b/apps/mobile/package-lock.json index 727be4d..e332440 100644 --- a/apps/mobile/package-lock.json +++ b/apps/mobile/package-lock.json @@ -17,6 +17,7 @@ "expo-dev-client": "~57.0.18", "expo-linking": "~57.0.9", "expo-router": "~57.0.19", + "expo-secure-store": "~57.0.3", "expo-splash-screen": "~57.0.8", "expo-status-bar": "~57.0.1", "react": "19.2.3", @@ -7015,6 +7016,15 @@ } } }, + "node_modules/expo-secure-store": { + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-57.0.3.tgz", + "integrity": "sha512-w7XkSQeUiYXPoKXo1jSrQqql7pyCSyIzOp2k0apsZZBE+RkoLTQIMjcbqc181y6KgupfNnYxkaKe+4MEg94+SA==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-server": { "version": "57.0.3", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-57.0.3.tgz", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 7e3fe09..191df0c 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -12,6 +12,7 @@ "expo-dev-client": "~57.0.18", "expo-linking": "~57.0.9", "expo-router": "~57.0.19", + "expo-secure-store": "~57.0.3", "expo-splash-screen": "~57.0.8", "expo-status-bar": "~57.0.1", "react": "19.2.3", diff --git a/apps/mobile/scripts/android-phase1-e2e.mjs b/apps/mobile/scripts/android-phase1-e2e.mjs index fb02826..b1c758d 100644 --- a/apps/mobile/scripts/android-phase1-e2e.mjs +++ b/apps/mobile/scripts/android-phase1-e2e.mjs @@ -5,10 +5,13 @@ const ballotKey = process.env.RCV_E2E_BALLOT_KEY ?? 'pizza'; const voterCode = process.env.RCV_E2E_VOTER_CODE?.trim(); const groupOptionLabel = process.env.RCV_E2E_GROUP_OPTION_LABEL?.trim(); const shareOnly = process.env.RCV_E2E_SHARE_ONLY === '1'; +const createBallot = process.env.RCV_E2E_CREATE_BALLOT === '1'; const appPackage = process.env.RCV_E2E_APP_PACKAGE ?? 'host.exp.exponent'; const incomingUrl = process.env.RCV_E2E_INCOMING_URL ?? - `exp://127.0.0.1:8081/--/ballot/${encodeURIComponent(ballotKey)}`; + (createBallot + ? 'exp://127.0.0.1:8081/--/create' + : `exp://127.0.0.1:8081/--/ballot/${encodeURIComponent(ballotKey)}`); const coldStart = process.env.RCV_E2E_COLD_START !== '0'; if (voterCode && !/^[A-Za-z0-9]{6}$/.test(voterCode)) { @@ -78,7 +81,41 @@ const startArguments = [ ]; run(...startArguments); -let xml = waitFor(/text="Shortcode: [^"]+"/, 'the incoming ballot link'); +let xml; + +if (createBallot) { + const suffix = String(Date.now()).slice(-6); + const fields = [ + ['Ballot name', `E2E-${suffix}`], + ['Candidate 1', `Alpha-${suffix}`], + ['Candidate 2', `Beta-${suffix}`], + ]; + + xml = waitFor(/content-desc="Ballot name"/, 'the basic ballot form'); + for (const [label, value] of fields) { + const pattern = new RegExp(`content-desc="${escapeRegExp(label)}"[^>]*bounds="([^"]+)"`); + xml = scrollUntil(pattern, `${label} field`); + tapMatching(xml, pattern, `${label} field`); + run('shell', 'input', 'text', value); + run('shell', 'input', 'keyevent', '4'); + } + + xml = scrollUntil(/content-desc="Create ballot"[^>]*bounds="([^"]+)"/, 'the create button'); + tapMatching(xml, /content-desc="Create ballot"[^>]*bounds="([^"]+)"/, 'the create button'); + xml = waitFor(/text="BALLOT CREATED"/, 'the created-ballot state'); + waitFor(/text="Management access is protected on this device\."/, 'encrypted credential storage'); + + const shortcode = xml.match(/text="([a-f0-9]{8})"/)?.[1]; + if (!shortcode) throw new Error('Could not read the created ballot shortcode.'); + + xml = scrollUntil(/content-desc="Open ballot"[^>]*bounds="([^"]+)"/, 'the open-ballot button'); + tapMatching(xml, /content-desc="Open ballot"[^>]*bounds="([^"]+)"/, 'the open-ballot button'); + waitFor(new RegExp(`text="Shortcode: ${shortcode}"`), 'the newly created ballot'); + console.log(`Android guest-ballot creation E2E passed for shortcode ${shortcode}`); + process.exit(0); +} + +xml = waitFor(/text="Shortcode: [^"]+"/, 'the incoming ballot link'); if (shareOnly) { tapMatching( diff --git a/apps/mobile/src/api/v2-api.test.ts b/apps/mobile/src/api/v2-api.test.ts index 2d93a53..407995a 100644 --- a/apps/mobile/src/api/v2-api.test.ts +++ b/apps/mobile/src/api/v2-api.test.ts @@ -11,6 +11,89 @@ const request = { voterCode: 'abcooi', }; +const createdBallot = { + status: 'created', + ballot: { id: 42, key: '12ab34cd', name: 'Lunch', positions: 1 }, + candidates: [ + { id: 100, name: 'Tacos' }, + { id: 101, name: 'Salad' }, + ], + managementToken: 'a'.repeat(43), +}; + +describe('V2ApiClient.createBallot', () => { + it('creates a basic ballot and accepts the one-time management credential', async () => { + const fetchImpl = vi.fn(async () => + new Response(JSON.stringify({ data: createdBallot, error: null }), { status: 201 }), + ); + const client = new V2ApiClient({ baseUrl: 'https://example.test/api/', fetchImpl }); + + await expect( + client.createBallot({ name: 'Lunch', candidates: ['Tacos', 'Salad'] }), + ).resolves.toEqual(createdBallot); + expect(fetchImpl).toHaveBeenCalledWith('https://example.test/api/v2/ballots.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Lunch', candidates: ['Tacos', 'Salad'] }), + signal: undefined, + }); + }); + + it('preserves server field errors for the creation form', async () => { + const client = new V2ApiClient({ + baseUrl: 'https://example.test/api', + fetchImpl: async () => + new Response( + JSON.stringify({ + data: null, + error: { + code: 'validation_failed', + message: 'Check the ballot details.', + fields: { name: 'Enter a name.', 'candidates.1': 'Candidate names must be unique.' }, + }, + }), + { status: 422 }, + ), + }); + + await expect(client.createBallot({ name: '', candidates: ['A', 'A'] })).rejects.toMatchObject({ + code: 'validation_failed', + fields: { name: 'Enter a name.', 'candidates.1': 'Candidate names must be unique.' }, + retryable: false, + status: 422, + }); + }); + + it('treats a lost creation response as uncertain and unsafe to retry', async () => { + const client = new V2ApiClient({ + baseUrl: 'https://example.test/api', + fetchImpl: async () => { + throw new Error('connection lost'); + }, + }); + + await expect(client.createBallot({ name: 'Lunch', candidates: ['A', 'B'] })).rejects.toMatchObject({ + code: 'creation_unknown', + retryable: false, + }); + }); + + it('rejects success data without a valid management credential', async () => { + const client = new V2ApiClient({ + baseUrl: 'https://example.test/api', + fetchImpl: async () => + new Response( + JSON.stringify({ data: { ...createdBallot, managementToken: 'short' }, error: null }), + { status: 201 }, + ), + }); + + await expect(client.createBallot({ name: 'Lunch', candidates: ['A', 'B'] })).rejects.toMatchObject({ + code: 'malformed_response', + }); + }); +}); + describe('V2ApiClient.submitVote', () => { it('submits typed rankings and returns the accepted response', async () => { const fetchImpl = vi.fn(async () => diff --git a/apps/mobile/src/api/v2-api.ts b/apps/mobile/src/api/v2-api.ts index 6e148b3..e736fc8 100644 --- a/apps/mobile/src/api/v2-api.ts +++ b/apps/mobile/src/api/v2-api.ts @@ -13,6 +13,23 @@ export type SubmitVoteResult = { replayed: boolean; }; +export type CreateBallotRequest = { + name: string; + candidates: string[]; +}; + +export type CreatedBallot = { + status: 'created'; + ballot: { + id: number; + key: string; + name: string; + positions: number; + }; + candidates: { id: number; name: string }[]; + managementToken: string; +}; + export type ElectionResults = { ballot: { key: string; @@ -25,6 +42,7 @@ export type ElectionResults = { }; export type V2ApiErrorCode = + | 'invalid_json' | 'validation_failed' | 'ballot_not_found' | 'results_not_released' @@ -40,6 +58,7 @@ export type V2ApiErrorCode = | 'invalid_ranking' | 'server_error' | 'network' + | 'creation_unknown' | 'http' | 'malformed_response'; @@ -49,6 +68,7 @@ export class V2ApiError extends Error { message: string, public readonly retryable = false, public readonly status?: number, + public readonly fields?: Record, ) { super(message); this.name = 'V2ApiError'; @@ -71,6 +91,7 @@ function isKnownErrorCode(value: unknown): value is V2ApiErrorCode { typeof value === 'string' && [ 'validation_failed', + 'invalid_json', 'ballot_not_found', 'results_not_released', 'idempotency_conflict', @@ -97,6 +118,34 @@ export class V2ApiClient { this.fetchImpl = fetchImpl; } + async createBallot( + request: CreateBallotRequest, + signal?: AbortSignal, + ): Promise { + let response: Response; + try { + response = await this.fetchImpl(`${this.baseUrl}/v2/ballots.php`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + signal, + }); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') throw error; + throw new V2ApiError( + 'creation_unknown', + 'The connection ended before creation could be confirmed. The ballot may have been created, so do not submit it again yet.', + ); + } + + const envelope = await this.parseEnvelope(response); + if (envelope.error !== null) throw this.normalizeError(envelope.error, response.status); + if (!response.ok || !isCreatedBallot(envelope.data)) { + throw new V2ApiError('malformed_response', 'The ballot server returned invalid success data.'); + } + return envelope.data; + } + async getResults(key: string, signal?: AbortSignal): Promise { let response: Response; try { @@ -180,10 +229,49 @@ export class V2ApiClient { error.message, error.code === 'server_error' || status >= 500, status, + normalizeErrorFields(error.fields), ); } } +function normalizeErrorFields(value: unknown): Record | undefined { + if (!isRecord(value)) return undefined; + const entries = Object.entries(value); + if (!entries.every(([, message]) => typeof message === 'string')) return undefined; + return Object.fromEntries(entries) as Record; +} + +function isCreatedBallot(value: unknown): value is CreatedBallot { + if ( + !isRecord(value) || + value.status !== 'created' || + !isRecord(value.ballot) || + !Array.isArray(value.candidates) || + typeof value.managementToken !== 'string' + ) { + return false; + } + + const ballot = value.ballot; + return ( + Number.isInteger(ballot.id) && + (ballot.id as number) > 0 && + typeof ballot.key === 'string' && + /^[a-f0-9]{8}$/.test(ballot.key) && + typeof ballot.name === 'string' && + ballot.positions === 1 && + value.candidates.length >= 2 && + value.candidates.every( + (candidate) => + isRecord(candidate) && + Number.isInteger(candidate.id) && + (candidate.id as number) > 0 && + typeof candidate.name === 'string', + ) && + /^[A-Za-z0-9_-]{43}$/.test(value.managementToken) + ); +} + function isElectionResults(value: unknown): value is ElectionResults { if (!isRecord(value) || !isRecord(value.ballot) || !Array.isArray(value.candidates) || !Array.isArray(value.votes)) { return false; diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 83c1b02..8a14fc3 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -14,6 +14,7 @@ export default function RootLayout() { contentStyle: { backgroundColor: '#f5f7fa' }, }}> + diff --git a/apps/mobile/src/app/create.tsx b/apps/mobile/src/app/create.tsx new file mode 100644 index 0000000..9766f45 --- /dev/null +++ b/apps/mobile/src/app/create.tsx @@ -0,0 +1,330 @@ +import * as SecureStore from 'expo-secure-store'; +import { useRouter } from 'expo-router'; +import { useMemo, useState } from 'react'; +import { + ActivityIndicator, + KeyboardAvoidingView, + Platform, + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +import { V2ApiClient, V2ApiError, type CreatedBallot } from '@/api/v2-api'; +import { BallotShareButton } from '@/components/ballot-share-button'; +import { getApiBaseUrl } from '@/config/api'; +import { validateGuestBallot, type GuestBallotFieldErrors } from '@/features/guest-ballot'; +import { saveBallotManagementToken } from '@/utils/ballot-management-token-store'; + +type CreatedBallotSummary = Omit; +type SubmissionState = 'editing' | 'submitting' | 'created' | 'storage-error' | 'error'; + +const emptyErrors = (): GuestBallotFieldErrors => ({ candidateNames: {} }); + +export default function CreateBallotScreen() { + const router = useRouter(); + const client = useMemo(() => new V2ApiClient({ baseUrl: getApiBaseUrl() }), []); + const [name, setName] = useState(''); + const [candidates, setCandidates] = useState(['', '']); + const [fieldErrors, setFieldErrors] = useState(emptyErrors); + const [submissionState, setSubmissionState] = useState('editing'); + const [message, setMessage] = useState(''); + const [createdBallot, setCreatedBallot] = useState(null); + const [pendingManagementToken, setPendingManagementToken] = useState(null); + + const updateCandidate = (index: number, value: string) => { + setCandidates((current) => current.map((candidate, candidateIndex) => + candidateIndex === index ? value : candidate, + )); + }; + + const removeCandidate = (index: number) => { + if (candidates.length <= 2) return; + setCandidates((current) => current.filter((_, candidateIndex) => candidateIndex !== index)); + }; + + const storeCreatedBallot = async (result: CreatedBallot) => { + const { managementToken, ...summary } = result; + try { + await saveBallotManagementToken(SecureStore, result.ballot.key, managementToken); + setCreatedBallot(summary); + setPendingManagementToken(null); + setMessage('Management access is protected on this device.'); + setSubmissionState('created'); + } catch { + // Keep the credential only in memory so the user can retry storage while + // this screen remains open. Never display or log it. + setCreatedBallot(summary); + setPendingManagementToken(managementToken); + setMessage( + 'Your ballot was created, but this device could not save management access. Keep this screen open and try saving again.', + ); + setSubmissionState('storage-error'); + } + }; + + const createBallot = async () => { + const validation = validateGuestBallot(name, candidates); + if (!validation.ok) { + setFieldErrors(validation.errors); + setMessage('Check the highlighted details.'); + setSubmissionState('error'); + return; + } + + setFieldErrors(emptyErrors()); + setMessage(''); + + try { + if (Platform.OS === 'web' || !(await SecureStore.isAvailableAsync())) { + setMessage('Basic ballot creation currently requires the iOS or Android app.'); + setSubmissionState('error'); + return; + } + } catch { + setMessage('Encrypted device storage is unavailable, so a manageable ballot cannot be created.'); + setSubmissionState('error'); + return; + } + + setSubmissionState('submitting'); + try { + await storeCreatedBallot(await client.createBallot(validation.request)); + } catch (error) { + if (error instanceof V2ApiError) { + const serverFields = error.fields ?? {}; + const candidateNames: Record = {}; + Object.entries(serverFields).forEach(([field, fieldMessage]) => { + const match = /^candidates\.(\d+)$/.exec(field); + if (match) candidateNames[Number(match[1])] = fieldMessage; + }); + setFieldErrors({ + name: serverFields.name, + candidates: serverFields.candidates, + candidateNames, + }); + setMessage(error.message); + } else { + setMessage('The ballot could not be created. Try again.'); + } + setSubmissionState('error'); + } + }; + + const retryCredentialStorage = async () => { + if (!createdBallot || !pendingManagementToken) return; + setSubmissionState('submitting'); + try { + await saveBallotManagementToken( + SecureStore, + createdBallot.ballot.key, + pendingManagementToken, + ); + setPendingManagementToken(null); + setMessage('Management access is protected on this device.'); + setSubmissionState('created'); + } catch { + setMessage('Management access still could not be saved. Keep this screen open and try again.'); + setSubmissionState('storage-error'); + } + }; + + if (createdBallot) { + return ( + + + + BALLOT CREATED + {createdBallot.ballot.name} + + Share this shortcode so people can open and vote on your ballot. + + Ballot shortcode + {createdBallot.ballot.key} + + {message} + + + {submissionState === 'created' ? ( + + Until account claiming is available, losing this device or its app data may mean + losing management access. + + ) : null} + + {pendingManagementToken ? ( + [styles.primaryButton, pressed && styles.pressed]}> + {submissionState === 'submitting' ? : ( + Try saving access again + )} + + ) : ( + <> + + router.replace({ + pathname: '/ballot/[key]', + params: { key: createdBallot.ballot.key }, + })} + style={({ pressed }) => [styles.primaryButton, pressed && styles.pressed]}> + Open ballot + + + )} + + + + ); + } + + const submitting = submissionState === 'submitting'; + return ( + + + + + BASIC BALLOT + Create a ranked-choice ballot + + Add a name and at least two candidates. Advanced settings remain available on + RankedChoices.com. + + + Ballot name + + {fieldErrors.name ? {fieldErrors.name} : null} + + Candidates + {candidates.map((candidate, index) => ( + + + Candidate {index + 1} + {candidates.length > 2 ? ( + removeCandidate(index)}> + Remove + + ) : null} + + updateCandidate(index, value)} + placeholder="Candidate name" + returnKeyType="next" + style={[styles.input, fieldErrors.candidateNames[index] && styles.inputError]} + value={candidate} + /> + {fieldErrors.candidateNames[index] ? ( + {fieldErrors.candidateNames[index]} + ) : null} + + ))} + {fieldErrors.candidates ? {fieldErrors.candidates} : null} + + {candidates.length < 100 ? ( + setCandidates((current) => [...current, ''])} + style={({ pressed }) => [styles.secondaryButton, pressed && styles.pressed]}> + Add candidate + + ) : null} + + {message ? ( + {message} + ) : null} + [styles.primaryButton, pressed && styles.pressed]}> + {submitting ? : ( + Create ballot + )} + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: '#f5f7fa' }, + safeArea: { flex: 1, backgroundColor: '#f5f7fa' }, + scrollContent: { flexGrow: 1, padding: 24 }, + card: { + width: '100%', maxWidth: 520, alignSelf: 'center', borderRadius: 20, + backgroundColor: '#ffffff', padding: 24, shadowColor: '#0d2033', + shadowOffset: { width: 0, height: 8 }, shadowOpacity: 0.12, shadowRadius: 24, elevation: 4, + }, + eyebrow: { color: '#b24c00', fontSize: 12, fontWeight: '800', letterSpacing: 1.2 }, + title: { color: '#12355b', fontSize: 30, fontWeight: '800', lineHeight: 36, marginTop: 10 }, + description: { color: '#40556b', fontSize: 16, lineHeight: 24, marginTop: 12, marginBottom: 24 }, + label: { color: '#1f3143', fontSize: 14, fontWeight: '700', marginBottom: 8 }, + sectionTitle: { color: '#12355b', fontSize: 20, fontWeight: '800', marginTop: 24, marginBottom: 14 }, + candidateGroup: { marginBottom: 16 }, + candidateHeading: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, + input: { + borderColor: '#9aabba', borderRadius: 12, borderWidth: 1, color: '#14283b', + fontSize: 17, paddingHorizontal: 14, paddingVertical: 13, + }, + inputError: { borderColor: '#a6261d', borderWidth: 2 }, + error: { color: '#a6261d', fontSize: 14, lineHeight: 20, marginTop: 8 }, + success: { color: '#146c43', fontSize: 14, lineHeight: 20, marginTop: 12 }, + deviceNote: { color: '#40556b', fontSize: 13, lineHeight: 19, marginTop: 10 }, + removeText: { color: '#a6261d', fontSize: 14, fontWeight: '700', marginBottom: 8 }, + primaryButton: { + alignItems: 'center', backgroundColor: '#146c43', borderRadius: 12, + marginTop: 18, minHeight: 48, justifyContent: 'center', paddingHorizontal: 18, paddingVertical: 14, + }, + primaryButtonText: { color: '#ffffff', fontSize: 16, fontWeight: '800' }, + secondaryButton: { + alignItems: 'center', borderColor: '#146c43', borderRadius: 12, borderWidth: 1, + alignSelf: 'flex-start', paddingHorizontal: 16, paddingVertical: 11, + }, + secondaryButtonText: { color: '#146c43', fontSize: 15, fontWeight: '800' }, + pressed: { opacity: 0.78 }, + shortcodeLabel: { color: '#40556b', fontSize: 14, fontWeight: '700' }, + shortcode: { + color: '#12355b', fontSize: 30, fontWeight: '800', letterSpacing: 2, + marginTop: 5, paddingVertical: 6, + }, +}); diff --git a/apps/mobile/src/app/index.tsx b/apps/mobile/src/app/index.tsx index f4bc512..ae6efdd 100644 --- a/apps/mobile/src/app/index.tsx +++ b/apps/mobile/src/app/index.tsx @@ -35,7 +35,7 @@ export default function HomeScreen() { style={styles.container}> - PHASE 1 + OPEN A BALLOT Open a ranked-choice ballot Enter the shortcode from a Ranked Choices ballot to rank choices and submit an @@ -62,6 +62,15 @@ export default function HomeScreen() { style={({ pressed }) => [styles.button, pressed && styles.buttonPressed]}> Find ballot + + + Starting a new vote? + router.push('/create')} + style={({ pressed }) => [styles.createButton, pressed && styles.buttonPressed]}> + Create a basic ballot + @@ -148,4 +157,28 @@ const styles = StyleSheet.create({ fontSize: 16, fontWeight: '800', }, + divider: { + backgroundColor: '#dce3e9', + height: 1, + marginVertical: 24, + }, + createPrompt: { + color: '#40556b', + fontSize: 15, + marginBottom: 10, + textAlign: 'center', + }, + createButton: { + alignItems: 'center', + borderColor: '#146c43', + borderRadius: 12, + borderWidth: 1, + paddingHorizontal: 18, + paddingVertical: 13, + }, + createButtonText: { + color: '#146c43', + fontSize: 16, + fontWeight: '800', + }, }); diff --git a/apps/mobile/src/features/guest-ballot.test.ts b/apps/mobile/src/features/guest-ballot.test.ts new file mode 100644 index 0000000..6e10a74 --- /dev/null +++ b/apps/mobile/src/features/guest-ballot.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { validateGuestBallot } from './guest-ballot'; + +describe('validateGuestBallot', () => { + it('trims a basic name-and-candidates ballot into an API request', () => { + expect(validateGuestBallot(' Lunch ', [' Tacos ', 'Salad'])).toEqual({ + ok: true, + request: { name: 'Lunch', candidates: ['Tacos', 'Salad'] }, + }); + }); + + it('requires a name and at least two populated candidates', () => { + expect(validateGuestBallot(' ', ['Only one'])).toEqual({ + ok: false, + errors: { + name: 'Enter a ballot name of 64 characters or fewer.', + candidates: 'Enter between 2 and 100 candidates.', + candidateNames: {}, + }, + }); + expect(validateGuestBallot('Lunch', ['', 'Salad'])).toMatchObject({ + ok: false, + errors: { candidateNames: { 0: expect.any(String) } }, + }); + }); + + it('rejects duplicate names without regard to case or surrounding space', () => { + expect(validateGuestBallot('Lunch', ['Tacos', ' tacos '])).toMatchObject({ + ok: false, + errors: { candidateNames: { 1: 'Candidate names must be unique.' } }, + }); + }); + + it('matches the server text limits and legacy database character boundary', () => { + expect(validateGuestBallot('a'.repeat(65), ['A', 'B'])).toMatchObject({ + ok: false, + errors: { name: expect.any(String) }, + }); + expect(validateGuestBallot('Lunch', ['A', 'B\u0000'])).toMatchObject({ + ok: false, + errors: { candidateNames: { 1: expect.any(String) } }, + }); + expect(validateGuestBallot('Lunch', ['A', '🍕'])).toMatchObject({ + ok: false, + errors: { candidateNames: { 1: expect.any(String) } }, + }); + }); +}); diff --git a/apps/mobile/src/features/guest-ballot.ts b/apps/mobile/src/features/guest-ballot.ts new file mode 100644 index 0000000..7cf043d --- /dev/null +++ b/apps/mobile/src/features/guest-ballot.ts @@ -0,0 +1,66 @@ +import type { CreateBallotRequest } from '@/api/v2-api'; + +export type GuestBallotFieldErrors = { + name?: string; + candidates?: string; + candidateNames: Record; +}; + +export type GuestBallotValidation = + | { ok: true; request: CreateBallotRequest } + | { ok: false; errors: GuestBallotFieldErrors }; + +const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/u; +const ASTRAL_CHARACTERS = /[\u{10000}-\u{10ffff}]/u; + +function normalizeText(value: string, maxCharacters: number): string | null { + const normalized = value.trim(); + if ( + !normalized || + CONTROL_CHARACTERS.test(normalized) || + ASTRAL_CHARACTERS.test(normalized) || + Array.from(normalized).length > maxCharacters + ) { + return null; + } + return normalized; +} + +export function validateGuestBallot( + nameValue: string, + candidateValues: string[], +): GuestBallotValidation { + const errors: GuestBallotFieldErrors = { candidateNames: {} }; + const name = normalizeText(nameValue, 64); + if (name === null) { + errors.name = 'Enter a ballot name of 64 characters or fewer.'; + } + + if (candidateValues.length < 2 || candidateValues.length > 100) { + errors.candidates = 'Enter between 2 and 100 candidates.'; + } + + const candidates: string[] = []; + const seen = new Set(); + candidateValues.forEach((candidateValue, index) => { + const candidate = normalizeText(candidateValue, 256); + if (candidate === null) { + errors.candidateNames[index] = 'Enter a candidate name of 256 characters or fewer.'; + return; + } + + const key = candidate.toLowerCase(); + if (seen.has(key)) { + errors.candidateNames[index] = 'Candidate names must be unique.'; + return; + } + seen.add(key); + candidates.push(candidate); + }); + + if (errors.name || errors.candidates || Object.keys(errors.candidateNames).length > 0) { + return { ok: false, errors }; + } + + return { ok: true, request: { name: name as string, candidates } }; +} diff --git a/apps/mobile/src/utils/ballot-management-token-store.test.ts b/apps/mobile/src/utils/ballot-management-token-store.test.ts new file mode 100644 index 0000000..3733bbf --- /dev/null +++ b/apps/mobile/src/utils/ballot-management-token-store.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { ballotManagementTokenStorageKey, saveBallotManagementToken } from './ballot-management-token-store'; + +const token = 'a'.repeat(43); + +describe('ballot management token storage', () => { + it('stores the credential under a ballot-scoped, non-secret key', async () => { + const storage = { + isAvailableAsync: vi.fn(async () => true), + setItemAsync: vi.fn(async () => undefined), + }; + + await saveBallotManagementToken(storage, '12ab34cd', token); + + expect(storage.setItemAsync).toHaveBeenCalledWith( + 'rankedchoices.ballot.12ab34cd.management-token', + token, + ); + expect(ballotManagementTokenStorageKey('12ab34cd')).not.toContain(token); + }); + + it('fails before writing when encrypted storage is unavailable', async () => { + const storage = { + isAvailableAsync: vi.fn(async () => false), + setItemAsync: vi.fn(async () => undefined), + }; + + await expect(saveBallotManagementToken(storage, '12ab34cd', token)).rejects.toThrow( + 'Encrypted device storage is unavailable.', + ); + expect(storage.setItemAsync).not.toHaveBeenCalled(); + }); + + it('rejects malformed keys and tokens', async () => { + const storage = { + isAvailableAsync: vi.fn(async () => true), + setItemAsync: vi.fn(async () => undefined), + }; + + await expect(saveBallotManagementToken(storage, 'bad/key', token)).rejects.toThrow(); + await expect(saveBallotManagementToken(storage, '12ab34cd', 'not-a-token')).rejects.toThrow(); + expect(storage.setItemAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/utils/ballot-management-token-store.ts b/apps/mobile/src/utils/ballot-management-token-store.ts new file mode 100644 index 0000000..3c62b9d --- /dev/null +++ b/apps/mobile/src/utils/ballot-management-token-store.ts @@ -0,0 +1,28 @@ +export type SecureStringStorage = { + isAvailableAsync(): Promise; + setItemAsync(key: string, value: string): Promise; +}; + +const MANAGEMENT_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/; +const BALLOT_KEY_PATTERN = /^[A-Za-z0-9._-]+$/; + +export function ballotManagementTokenStorageKey(ballotKey: string): string { + if (!BALLOT_KEY_PATTERN.test(ballotKey)) { + throw new Error('Cannot store a credential for an invalid ballot key.'); + } + return `rankedchoices.ballot.${ballotKey}.management-token`; +} + +export async function saveBallotManagementToken( + storage: SecureStringStorage, + ballotKey: string, + managementToken: string, +): Promise { + if (!MANAGEMENT_TOKEN_PATTERN.test(managementToken)) { + throw new Error('Cannot store an invalid ballot management credential.'); + } + if (!(await storage.isAvailableAsync())) { + throw new Error('Encrypted device storage is unavailable.'); + } + await storage.setItemAsync(ballotManagementTokenStorageKey(ballotKey), managementToken); +} diff --git a/docs/expo-migration-rfc.md b/docs/expo-migration-rfc.md index 6687663..891809e 100644 --- a/docs/expo-migration-rfc.md +++ b/docs/expo-migration-rfc.md @@ -382,6 +382,11 @@ and Android, including failure recovery, without regressing the website. ### Phase 2 — ballot creation and secure voting +Implementation status: secure-code voting, grouping questions, canonical +sharing, the guest-creation API, and native basic creation are implemented in +reviewable vertical slices. Advanced creation and authenticated claiming remain +deferred as described below. + - Port a name-and-candidates version of the 15-second create flow first. - Add optional settings progressively, not as an initial wizard. - Issue a server-generated guest management token, store only its digest on the