diff --git a/libs/ui/src/globals.ts b/libs/ui/src/globals.ts index 21cf4d8cb5..3624bbb025 100644 --- a/libs/ui/src/globals.ts +++ b/libs/ui/src/globals.ts @@ -1,9 +1,11 @@ +import { PinLength } from './utils/pin_length'; + export const CHECK_ICON = '✓'; export const FONT_SIZES = [22, 28, 36, 56]; export const DEFAULT_FONT_SIZE = 1; export const LARGE_DISPLAY_FONT_SIZE = 3; -export const SECURITY_PIN_LENGTH = 6; +export const SECURITY_PIN_LENGTH = PinLength.exactly(6); export const DEFAULT_NUMBER_POLL_REPORT_COPIES = 2; diff --git a/libs/ui/src/hooks/use_pin_entry.test.ts b/libs/ui/src/hooks/use_pin_entry.test.ts new file mode 100644 index 0000000000..5882ed5fba --- /dev/null +++ b/libs/ui/src/hooks/use_pin_entry.test.ts @@ -0,0 +1,70 @@ +import { act, renderHook } from '@testing-library/react'; +import { usePinEntry } from './use_pin_entry'; +import { PinLength } from '../utils/pin_length'; + +test('defaults', () => { + const pinEntry = renderHook(() => + usePinEntry({ pinLength: PinLength.exactly(6) }) + ); + + expect(pinEntry.result.current).toEqual( + expect.objectContaining({ + current: '', + display: '- - - - - -', + }) + ); +}); + +test('PIN entry', () => { + const pinEntry = renderHook(() => + usePinEntry({ pinLength: PinLength.exactly(6) }) + ); + + act(() => { + pinEntry.result.current.handleDigit(1); + }); + act(() => { + pinEntry.result.current.handleDigit(2); + }); + act(() => { + pinEntry.result.current.handleDigit(3); + }); + act(() => { + pinEntry.result.current.handleDigit(4); + }); + act(() => { + pinEntry.result.current.handleDigit(5); + }); + act(() => { + pinEntry.result.current.handleDigit(6); + }); + + expect(pinEntry.result.current).toEqual( + expect.objectContaining({ + current: '123456', + display: '• • • • • •', + }) + ); + + act(() => { + pinEntry.result.current.handleBackspace(); + }); + + expect(pinEntry.result.current).toEqual( + expect.objectContaining({ + current: '12345', + display: '• • • • • -', + }) + ); + + act(() => { + pinEntry.result.current.reset(); + }); + + expect(pinEntry.result.current).toEqual( + expect.objectContaining({ + current: '', + display: '- - - - - -', + }) + ); +}); diff --git a/libs/ui/src/hooks/use_pin_entry.ts b/libs/ui/src/hooks/use_pin_entry.ts new file mode 100644 index 0000000000..494b66b9c6 --- /dev/null +++ b/libs/ui/src/hooks/use_pin_entry.ts @@ -0,0 +1,67 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { PinLength } from '../utils/pin_length'; + +/** + * Options for the {@link usePinEntry} hook. + */ +export interface UsePinEntryOptions { + pinLength: PinLength; +} + +/** + * Returned by the {@link usePinEntry} hook. + */ +export interface PinEntry { + current: string; + display: string; + setCurrent: React.Dispatch>; + reset: () => string; + handleDigit: (digit: number) => string; + handleBackspace: () => string; +} + +/** + * A hook for managing PIN entry. The returned object contains the current PIN, + * a display string for the PIN, and methods for updating the PIN. + */ +export function usePinEntry({ pinLength }: UsePinEntryOptions): PinEntry { + const [current, setCurrent] = useState(''); + + const reset = useCallback(() => { + setCurrent(''); + return ''; + }, []); + + const handleDigit = useCallback( + (digit: number) => { + const pin = `${current}${digit}`.slice(0, pinLength.max); + setCurrent(pin); + return pin; + }, + [current, pinLength.max] + ); + + const handleBackspace = useCallback(() => { + const pin = current.slice(0, -1); + setCurrent(pin); + return pin; + }, [current]); + + const display = '•' + .repeat(current.length) + .padEnd(pinLength.max, '-') + .split('') + .join(' '); + + return useMemo( + () => ({ + current, + display, + setCurrent, + reset, + handleDigit, + handleBackspace, + }), + [current, display, setCurrent, reset, handleDigit, handleBackspace] + ); +} diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts index 8d3c50cdf5..090907e107 100644 --- a/libs/ui/src/index.ts +++ b/libs/ui/src/index.ts @@ -26,6 +26,7 @@ export * from './hooks/use_devices'; export * from './hooks/use_lock'; export * from './hooks/use_mounted_state'; export * from './hooks/use_now'; +export * from './hooks/use_pin_entry'; export * from './hooks/use_previous'; export * from './hooks/use_screen_info'; export * from './hooks/use_usb_drive'; @@ -96,3 +97,4 @@ export * from './voter_contest_summary'; export * from './with_scroll_buttons'; export * from './search_select'; export * from './checkbox'; +export * from './utils/pin_length'; diff --git a/libs/ui/src/unlock_machine_screen.stories.tsx b/libs/ui/src/unlock_machine_screen.stories.tsx new file mode 100644 index 0000000000..9f30d3d1f0 --- /dev/null +++ b/libs/ui/src/unlock_machine_screen.stories.tsx @@ -0,0 +1,66 @@ +import { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; + +import { + UnlockMachineScreen, + UnlockMachineScreenProps, +} from './unlock_machine_screen'; +import { SECURITY_PIN_LENGTH } from './globals'; +import { PinLength } from './utils/pin_length'; + +type PropsAndCustomArgs = React.ComponentProps & { + minPinLength: number; + maxPinLength: number; +}; + +const initialProps: PropsAndCustomArgs = { + auth: { + user: { + role: 'election_manager', + electionHash: 'deadbeef', + jurisdiction: 'jxn', + }, + status: 'checking_pin', + }, + checkPin: async () => {}, + minPinLength: SECURITY_PIN_LENGTH.min, + maxPinLength: SECURITY_PIN_LENGTH.max, +}; + +const meta: Meta = { + title: 'libs-ui/UnlockMachineScreen', + component: UnlockMachineScreen, + args: initialProps, + argTypes: { + minPinLength: { + control: { + type: 'range', + min: 1, + max: 10, + step: 1, + }, + }, + maxPinLength: { + control: { + type: 'range', + min: 1, + max: 10, + step: 1, + }, + }, + pinLength: { + if: { global: 'showPinLength', exists: true }, + }, + }, +}; + +export default meta; + +export const Primary: StoryObj = { + render: ({ minPinLength, maxPinLength, ...rest }) => ( + + ), +}; diff --git a/libs/ui/src/unlock_machine_screen.test.tsx b/libs/ui/src/unlock_machine_screen.test.tsx index 2b2b0572c9..84b5e62136 100644 --- a/libs/ui/src/unlock_machine_screen.test.tsx +++ b/libs/ui/src/unlock_machine_screen.test.tsx @@ -8,6 +8,7 @@ import { DippedSmartCardAuth } from '@votingworks/types'; import { act, render, screen, waitFor } from '../test/react_testing_library'; import { UnlockMachineScreen } from './unlock_machine_screen'; +import { PinLength } from './utils/pin_length'; beforeEach(() => { jest.useFakeTimers().setSystemTime(new Date('2000-01-01T00:00:00Z')); @@ -141,3 +142,28 @@ test('Error checking PIN', () => { screen.getByText('Error checking PIN. Please try again.'); }); + +test('variable-length PIN', async () => { + const checkPin = jest.fn(); + render( + + ); + + // enter 4 digits + userEvent.click(screen.getButton('0')); + userEvent.click(screen.getButton('1')); + userEvent.click(screen.getButton('2')); + userEvent.click(screen.getButton('3')); + expect(checkPin).not.toHaveBeenCalled(); + + // press Enter + userEvent.click(screen.getButton('Enter')); + await waitFor(() => expect(checkPin).toHaveBeenCalledTimes(1)); +}); diff --git a/libs/ui/src/unlock_machine_screen.tsx b/libs/ui/src/unlock_machine_screen.tsx index 1aa01a6135..7530e8d1a5 100644 --- a/libs/ui/src/unlock_machine_screen.tsx +++ b/libs/ui/src/unlock_machine_screen.tsx @@ -7,12 +7,15 @@ import { Screen } from './screen'; import { Main } from './main'; import { Prose } from './prose'; import { fontSizeTheme } from './themes'; +import { Button } from './button'; import { NumberPad } from './number_pad'; import { SECURITY_PIN_LENGTH } from './globals'; import { useNow } from './hooks/use_now'; +import { usePinEntry } from './hooks/use_pin_entry'; import { Timer } from './timer'; import { P } from './typography'; import { Icons } from './icons'; +import { PinLength } from './utils/pin_length'; const NumberPadWrapper = styled.div` display: flex; @@ -41,48 +44,46 @@ type CheckingPinAuth = | DippedSmartCardAuth.CheckingPin | InsertedSmartCardAuth.CheckingPin; -interface Props { +export interface UnlockMachineScreenProps { auth: CheckingPinAuth; checkPin: (pin: string) => Promise; grayBackground?: boolean; + pinLength?: PinLength; } export function UnlockMachineScreen({ auth, checkPin, grayBackground, -}: Props): JSX.Element { - const [currentPin, setCurrentPin] = useState(''); + pinLength = SECURITY_PIN_LENGTH, +}: UnlockMachineScreenProps): JSX.Element { + const pinEntry = usePinEntry({ pinLength }); const [isCheckingPin, setIsCheckingPin] = useState(false); const now = useNow().toJSDate(); + const doCheckPin = useCallback( + async (pin: string) => { + setIsCheckingPin(true); + await checkPin(pin); + pinEntry.setCurrent(''); + setIsCheckingPin(false); + }, + [checkPin, pinEntry] + ); + const handleNumberEntry = useCallback( - async (digit: number) => { - const pin = `${currentPin}${digit}`.slice(0, SECURITY_PIN_LENGTH); - setCurrentPin(pin); - if (pin.length === SECURITY_PIN_LENGTH) { - setIsCheckingPin(true); - await checkPin(pin); - setCurrentPin(''); - setIsCheckingPin(false); + async (number: number) => { + const pin = pinEntry.handleDigit(number); + if (pin.length === pinLength.max) { + await doCheckPin(pin); } }, - [checkPin, currentPin] + [doCheckPin, pinEntry, pinLength.max] ); - const handleBackspace = useCallback(() => { - setCurrentPin((prev) => prev.slice(0, -1)); - }, []); - - const handleClear = useCallback(() => { - setCurrentPin(''); - }, []); - - const currentPinDisplayString = '•' - .repeat(currentPin.length) - .padEnd(SECURITY_PIN_LENGTH, '-') - .split('') - .join(' '); + const handleEnter = useCallback(async () => { + await doCheckPin(pinEntry.current); + }, [doCheckPin, pinEntry]); const isLockedOut = Boolean( auth.lockedOutUntil && now < new Date(auth.lockedOutUntil) @@ -120,14 +121,15 @@ export function UnlockMachineScreen({ maxWidth={false} > {primarySentence} - {currentPinDisplayString} + {pinEntry.display} + {!pinLength.isFixed && } diff --git a/libs/ui/src/utils/pin_length.test.ts b/libs/ui/src/utils/pin_length.test.ts new file mode 100644 index 0000000000..10dbada02f --- /dev/null +++ b/libs/ui/src/utils/pin_length.test.ts @@ -0,0 +1,25 @@ +import { PinLength } from './pin_length'; + +test('exactly', () => { + const p = PinLength.exactly(4); + expect(p.min).toEqual(4); + expect(p.max).toEqual(4); + expect(p.isFixed).toEqual(true); +}); + +test('range', () => { + const p = PinLength.range(4, 6); + expect(p.min).toEqual(4); + expect(p.max).toEqual(6); + expect(p.isFixed).toEqual(false); +}); + +test('invalid', () => { + expect(() => PinLength.exactly(0)).toThrowError('min must be > 0'); + expect(() => PinLength.exactly(-1)).toThrowError('min must be > 0'); + expect(() => PinLength.range(0, 1)).toThrowError('min must be > 0'); + expect(() => PinLength.range(3, 1)).toThrowError('min must be <= max'); + expect(() => PinLength.exactly(1.5)).toThrowError('min must be an integer'); + expect(() => PinLength.range(1.5, 2)).toThrowError('min must be an integer'); + expect(() => PinLength.range(1, 2.5)).toThrowError('max must be an integer'); +}); diff --git a/libs/ui/src/utils/pin_length.ts b/libs/ui/src/utils/pin_length.ts new file mode 100644 index 0000000000..18cd701b40 --- /dev/null +++ b/libs/ui/src/utils/pin_length.ts @@ -0,0 +1,55 @@ +import { assert } from '@votingworks/basics'; + +/** + * Represents a range of possible PIN lengths. + */ +export class PinLength { + private constructor( + private readonly minimum: number, + private readonly maximum: number + ) { + assert(minimum > 0, 'min must be > 0'); + assert(minimum <= maximum, 'min must be <= max'); + assert(Number.isInteger(minimum), 'min must be an integer'); + assert(Number.isInteger(maximum), 'max must be an integer'); + } + + /** + * Create a new `PinLength` representing an inclusive range of possible PIN + * lengths. + * + * @param min The minimum length of the PIN. + * @param max The maximum length of the PIN. + */ + static range(min: number, max: number): PinLength { + return new PinLength(min, max); + } + + /** + * Create a new `PinLength` representing a fixed-length PIN. + */ + static exactly(length: number): PinLength { + return new PinLength(length, length); + } + + /** + * The minimum length of the PIN. + */ + get min(): number { + return this.minimum; + } + + /** + * The maximum length of the PIN. + */ + get max(): number { + return this.maximum; + } + + /** + * Whether the PIN length is fixed. + */ + get isFixed(): boolean { + return this.minimum === this.maximum; + } +}