Skip to content

Commit

Permalink
feat(ui): allow variable-length PINs (#4083)
Browse files Browse the repository at this point in the history
VxSuite PINs are still always 6 digits. RAVE PINs are variable, and this is ported from RAVE since it may be useful in the future here and will make RAVE rebases easier.
  • Loading branch information
eventualbuddha authored Oct 12, 2023
1 parent ded2954 commit 99d51df
Show file tree
Hide file tree
Showing 9 changed files with 344 additions and 29 deletions.
4 changes: 3 additions & 1 deletion libs/ui/src/globals.ts
Original file line number Diff line number Diff line change
@@ -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;
70 changes: 70 additions & 0 deletions libs/ui/src/hooks/use_pin_entry.test.ts
Original file line number Diff line number Diff line change
@@ -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: '- - - - - -',
})
);
});
67 changes: 67 additions & 0 deletions libs/ui/src/hooks/use_pin_entry.ts
Original file line number Diff line number Diff line change
@@ -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<React.SetStateAction<string>>;
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]
);
}
2 changes: 2 additions & 0 deletions libs/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
66 changes: 66 additions & 0 deletions libs/ui/src/unlock_machine_screen.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof UnlockMachineScreen> & {
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<UnlockMachineScreenProps> = {
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<PropsAndCustomArgs> = {
render: ({ minPinLength, maxPinLength, ...rest }) => (
<UnlockMachineScreen
{...rest}
pinLength={PinLength.range(minPinLength, maxPinLength)}
/>
),
};
26 changes: 26 additions & 0 deletions libs/ui/src/unlock_machine_screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down Expand Up @@ -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(
<UnlockMachineScreen
auth={{
...checkingPinAuthStatus,
wrongPinEnteredAt: new Date(),
}}
checkPin={checkPin}
pinLength={PinLength.range(4, 6)}
/>
);

// 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));
});
58 changes: 30 additions & 28 deletions libs/ui/src/unlock_machine_screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -41,48 +44,46 @@ type CheckingPinAuth =
| DippedSmartCardAuth.CheckingPin
| InsertedSmartCardAuth.CheckingPin;

interface Props {
export interface UnlockMachineScreenProps {
auth: CheckingPinAuth;
checkPin: (pin: string) => Promise<void>;
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)
Expand Down Expand Up @@ -120,14 +121,15 @@ export function UnlockMachineScreen({
maxWidth={false}
>
{primarySentence}
<EnteredCode>{currentPinDisplayString}</EnteredCode>
<EnteredCode>{pinEntry.display}</EnteredCode>
<NumberPadWrapper>
<NumberPad
disabled={isCheckingPin || isLockedOut}
onButtonPress={handleNumberEntry}
onBackspace={handleBackspace}
onClear={handleClear}
onBackspace={pinEntry.handleBackspace}
onClear={pinEntry.reset}
/>
{!pinLength.isFixed && <Button onPress={handleEnter}>Enter</Button>}
</NumberPadWrapper>
</Prose>
</Main>
Expand Down
Loading

0 comments on commit 99d51df

Please sign in to comment.