on master help sign in
feat: initial implementation of final-form on login
commitf4fd597fbbf7beb9f7ba61425ff328a18d87f559 authorMatt Condon (shrugs) <mattgcondon@gmail.com> dateWed 31 Jul 2019, 21:33 (7 years ago) parent6b3335e
27 files changed+911−674
src/Bridge.js+3−3 src/components/Accordion.js+4−2 src/form/Autosaver.js+4−3 src/form/BridgeForm.js+2−2 src/form/FormError.js+10−3 src/form/Inputs.js+123−59 src/form/UploadInput.js+51 src/form/ValidationPauser.js+27 src/indigo-react/components/AccessoryIcon.js+1−1 src/indigo-react/components/CheckboxInput.js+2−11 src/indigo-react/components/Input.js+9−24 src/indigo-react/components/SelectInput.js+27−47 src/indigo-react/components/ToggleInput.js+12−22 src/lib/useInputs.js+2−4 src/lib/validators.js+13 src/views/Activate/ActivateCode.js+34−13 src/views/Activate/PassportVerify.js+16−5 src/views/Login.js+4−18 src/views/Login/Advanced.js+2−2 src/views/Login/ContinueButton.js+26 src/views/Login/Hardware.js+2−2 src/views/Login/Keystore.js+66−73 src/views/Login/Ledger.js+115−102 src/views/Login/Mnemonic.js+36−24 src/views/Login/PrivateKey.js+44−20 src/views/Login/Ticket.js+181−159 src/views/Login/Trezor.js+95−75
src/Bridge.js+3−3
@@ -10,7 +10,7 @@ import Provider from 'store/Provider';
1010import { ROUTE_NAMES } from 'lib/routeNames';
1111import { ROUTES } from 'lib/router';
1212import { NETWORK_TYPES } from 'lib/network';
13−import { walletFromMnemonic, WALLET_TYPES } from 'lib/wallet';
13+import { walletFromMnemonic } from 'lib/wallet';
1414import { isDevelopment } from 'lib/flags';
1515import useImpliedTicket from 'lib/useImpliedTicket';
1616import useHasDisclaimed from 'lib/useHasDisclaimed';
@@ -43,8 +43,8 @@ function useInitialRoutes() {
4343 if (IS_STUBBED) {
4444 return [
4545 { key: ROUTE_NAMES.LOGIN },
46− // { key: ROUTE_NAMES.POINTS },
47− // { key: ROUTE_NAMES.POINT },
46+ { key: ROUTE_NAMES.POINTS },
47+ { key: ROUTE_NAMES.POINT },
4848 ];
4949 }
5050
src/components/Accordion.js+4−2
@@ -2,11 +2,13 @@ import React from 'react';
22import { Grid, AccessoryIcon } from 'indigo-react';
33
44export default function Accordion({
5+ className,
56 views,
67 options,
78 currentTab,
89 onTabChange,
9− className,
10+
11+ // Tab props
1012 ...rest
1113}) {
1214 const Tab = views[currentTab];
@@ -37,7 +39,7 @@ export default function Accordion({
3739 </AccessoryIcon>
3840 </div>
3941 </Grid.Item>
40− {isActive && <Grid.Item full as={Tab} />}
42+ {isActive && <Grid.Item full as={Tab} {...rest} />}
4143 <Grid.Divider />
4244 </React.Fragment>
4345 );
src/form/Autosaver.js+4−3
@@ -1,7 +1,8 @@
11import { useEffect } from 'react';
2−import { useFormState } from 'react-final-form';
2+import { useFormState, useForm } from 'react-final-form';
33
44export default function Autosaver({ onValues }) {
5+ const form = useForm();
56 const { valid, validating, values } = useFormState({
67 subscription: {
78 valid: true,
@@ -12,9 +13,9 @@ export default function Autosaver({ onValues }) {
1213
1314 useEffect(() => {
1415 if (!validating) {
15− onValues && onValues({ valid: valid && !validating, values });
16+ onValues && onValues({ valid: valid && !validating, values, form });
1617 }
17− }, [onValues, valid, validating, values]);
18+ }, [form, onValues, valid, validating, values]);
1819
1920 return null;
2021}
src/form/BridgeForm.js+2−2
@@ -2,17 +2,17 @@ import React from 'react';
22import { Form } from 'react-final-form';
33import setFieldData from 'final-form-set-field-data';
44
5−import FormError from './FormError';
65import Autosaver from './Autosaver';
6+import ValidationPauser from './ValidationPauser';
77
88export default function BridgeForm({ children, onValues, ...rest }) {
99 return (
1010 <Form mutators={{ setFieldData }} {...rest}>
1111 {formProps => (
1212 <>
13+ <ValidationPauser />
1314 {children(formProps)}
1415 {onValues && <Autosaver onValues={onValues} />}
15− <FormError />
1616 </>
1717 )}
1818 </Form>
src/form/FormError.js+10−3
@@ -1,9 +1,16 @@
11import React from 'react';
22import { useFormState } from 'react-final-form';
33import { ErrorText } from 'indigo-react';
4+import { FORM_ERROR } from 'final-form';
45
5−export default function FormError() {
6− const { submitError } = useFormState({ subscription: { submitError: true } });
6+export default function FormError(props) {
7+ const { submitError, errors } = useFormState({
8+ subscription: { submitError: true, errors: true },
9+ });
710
8− return submitError ? <ErrorText>{submitError}</ErrorText> : null;
11+ const formError = errors[FORM_ERROR];
12+
13+ return submitError || formError ? (
14+ <ErrorText {...props}>{submitError || formError}</ErrorText>
15+ ) : null;
916}
src/form/Inputs.js+123−59
@@ -1,22 +1,23 @@
1−import React, { useMemo } from 'react';
1+import React from 'react';
22import { Input, AccessoryIcon } from 'indigo-react';
33import { useField } from 'react-final-form';
4+import { some } from 'lodash';
45
56import {
67 validateNotEmpty,
78 validateTicket,
89 kDefaultValidator,
910 validateMnemonic,
11+ validatePoint,
12+ validateMaximumPatpByteLength,
13+ validateOneOf,
14+ validateHexString,
15+ validateHexLength,
1016} from 'lib/validators';
1117import { compose } from 'lib/lib';
1218import { prependSig } from 'lib/transformers';
1319import { DEFAULT_HD_PATH } from 'lib/wallet';
14−
15−// TODO: why is `validate` called when routing away? new props?
16−// active not true: https://github.com/final-form/react-final-form/issues/558
17−
18−const kEmptyValidators = []; // necessary for referential stability
19−const kEmptyTransformers = []; // also for referential stability
20+import InputSigil from 'components/InputSigil';
2021
2122const PLACEHOLDER_POINT = '~sampel-ponnym';
2223const PLACEHOLDER_HD_PATH = DEFAULT_HD_PATH;
@@ -29,9 +30,8 @@ const PLACEHOLDER_PRIVATE_KEY =
2930
3031const buildValidator = (
3132 validators = [],
32− fn = x => undefined
33+ fn = () => undefined
3334) => async value => {
34− // console.log('validating:', value);
3535 return (
3636 compose(
3737 ...validators,
@@ -40,24 +40,78 @@ const buildValidator = (
4040 );
4141};
4242
43−const kTicketValidators = [validateTicket, validateNotEmpty];
44−export function TicketInput({
45− name,
46− validators = kEmptyValidators,
47− transformers = kEmptyTransformers,
48− validate,
49− ...rest
50−}) {
51− const { valid, error, validating } = useField(name, {
52− subscription: { error: true, validating: true },
53− });
43+export const hasErrors = obj => some(obj, v => v !== undefined);
44+
45+export const buildTicketValidator = (validators = []) =>
46+ buildValidator([...validators, validateTicket, validateNotEmpty]);
47+export const buildMnemonicValidator = () =>
48+ buildValidator([validateMnemonic, validateNotEmpty]);
49+export const buildCheckboxValidator = () =>
50+ buildValidator([validateOneOf([true, false])]);
51+export const buildPassphraseValidator = () => buildValidator([]);
52+// TODO: validate hdpath format
53+export const buildHdPathValidator = () => buildValidator([validateNotEmpty]);
54+export const buildPointValidator = (size = 4) =>
55+ buildValidator([
56+ validatePoint,
57+ validateMaximumPatpByteLength(size),
58+ validateNotEmpty,
59+ ]);
60+export const buildSelectValidator = options =>
61+ buildValidator([validateOneOf(options.map(option => option.value))]);
62+export const buildHexValidator = length =>
63+ buildValidator([
64+ validateHexLength(length),
65+ validateHexString,
66+ validateNotEmpty,
67+ ]);
68+export const buildUploadValidator = () => buildValidator([validateNotEmpty]);
69+
70+// the default form validator just returns field-level validations
71+const kDefaultFormValidator = (values, errors) => errors;
72+export const composeValidator = (
73+ fieldValidators = {},
74+ formValidator = kDefaultFormValidator
75+) => {
76+ const names = Object.keys(fieldValidators);
5477
55− const _validate = useMemo(
56− () => buildValidator([...validators, ...kTicketValidators], validate),
57− [validate, validators]
78+ const fieldLevelValidators = names.map(name => value =>
79+ fieldValidators[name](value)
5880 );
5981
60− const _format = (value, name) => prependSig(value);
82+ const fieldLevelValidator = async values => {
83+ const errors = await Promise.all(
84+ names.map((name, i) => fieldLevelValidators[i](values[name]))
85+ );
86+
87+ return names.reduce(
88+ (memo, name, i) => ({
89+ ...memo,
90+ [name]: errors[i],
91+ }),
92+ {}
93+ );
94+ };
95+
96+ return async values => {
97+ console.log('validating...');
98+ const errors = await fieldLevelValidator(values);
99+ return await formValidator(values, errors);
100+ };
101+};
102+
103+export function TicketInput({ name, ...rest }) {
104+ const {
105+ meta: { valid, error, validating, touched, active },
106+ } = useField(name, {
107+ subscription: {
108+ valid: true,
109+ error: true,
110+ validating: true,
111+ touched: true,
112+ active: true,
113+ },
114+ });
61115
62116 return (
63117 <Input
@@ -65,7 +119,7 @@ export function TicketInput({
65119 name={name}
66120 placeholder={PLACEHOLDER_TICKET}
67121 accessory={
68− error ? (
122+ touched && !active && error ? (
69123 <AccessoryIcon.Failure />
70124 ) : validating ? (
71125 <AccessoryIcon.Pending />
@@ -73,69 +127,79 @@ export function TicketInput({
73127 <AccessoryIcon.Success />
74128 ) : null
75129 }
76− config={{ validate: _validate, format: _format }}
130+ config={{ format: prependSig }}
77131 mono
78132 {...rest}
79133 />
80134 );
81135}
82136
83−const kMnemonicValidators = [validateMnemonic, validateNotEmpty];
84−export function MnemonicInput({
85− name,
86− validators = kEmptyValidators,
87− transformers = kEmptyTransformers,
88− validate,
89− ...rest
90−}) {
91− const _validate = useMemo(
92− () => buildValidator([...validators, ...kMnemonicValidators], validate),
93− [validate, validators]
137+export function MnemonicInput({ ...rest }) {
138+ return (
139+ <Input type="textarea" placeholder={PLACEHOLDER_MNEMONIC} mono {...rest} />
94140 );
141+}
95142
143+export function HdPathInput({ ...rest }) {
96144 return (
97145 <Input
98− type="textarea"
99− name={name}
100− placeholder={PLACEHOLDER_MNEMONIC}
101− config={{ validate: _validate }}
146+ type="text"
147+ placeholder={PLACEHOLDER_HD_PATH}
102148 autoComplete="off"
103− mono
104149 {...rest}
105150 />
106151 );
107152}
108153
109−const kHdPathValidators = [validateNotEmpty];
110−export function HdPathInput({
111− name,
112− validators = kEmptyValidators,
113− transformers = kEmptyTransformers,
114− validate,
115− ...rest
116−}) {
117− const _validate = useMemo(
118− () => buildValidator([...validators, ...kHdPathValidators], validate),
119− [validate, validators]
154+export function PassphraseInput({ ...rest }) {
155+ return (
156+ <Input
157+ type="password"
158+ placeholder="Passphrase"
159+ autoComplete="off"
160+ {...rest}
161+ />
120162 );
163+}
164+
165+export function PointInput({ name, size = 4, ...rest }) {
166+ const {
167+ input: { value },
168+ meta: { active, valid, error },
169+ } = useField(name, {
170+ subscription: { value: true, active: true, valid: true, error: true },
171+ });
121172
122173 return (
123174 <Input
124175 type="text"
176+ label="Point"
125177 name={name}
126− placeholder={PLACEHOLDER_HD_PATH}
127− config={{ validate: _validate }}
128− autoComplete="off"
178+ placeholder={PLACEHOLDER_POINT}
179+ accessory={
180+ value ? (
181+ <InputSigil
182+ patp={value}
183+ size={44}
184+ margin={8}
185+ pass={valid}
186+ focused={active}
187+ error={error}
188+ />
189+ ) : null
190+ }
191+ config={{ format: prependSig }}
192+ mono
129193 {...rest}
130194 />
131195 );
132196}
133197
134−export function PassphraseInput({ ...rest }) {
198+export function PrivateKeyInput({ ...rest }) {
135199 return (
136200 <Input
137− type="password"
138− placeholder="Passphrase"
201+ type="text"
202+ placeholder={PLACEHOLDER_PRIVATE_KEY}
139203 autoComplete="off"
140204 {...rest}
141205 />
src/form/UploadInput.jsnew file+51
@@ -0,0 +1,51 @@
1+import React, { useCallback } from 'react';
2+import { useField } from 'react-final-form';
3+import UploadButton from 'components/UploadButton';
4+import { AccessoryIcon } from 'indigo-react';
5+
6+export default function UploadInput({ name, label, ...rest }) {
7+ const {
8+ input,
9+ meta: { validating, submitting, touched, active, error },
10+ } = useField(name, { type: 'text' });
11+
12+ const handleUpload = useCallback(
13+ element => {
14+ input.onFocus();
15+
16+ const file = element.files.item(0);
17+ const reader = new FileReader();
18+
19+ reader.onload = e => {
20+ input.onChange({ target: { value: e.target.result } });
21+ };
22+
23+ const failure = _ => {
24+ input.onBlur();
25+ };
26+
27+ reader.onerror = failure;
28+ reader.onabort = failure;
29+
30+ reader.readAsText(file);
31+ },
32+ [input]
33+ );
34+
35+ return (
36+ <UploadButton
37+ onChange={handleUpload}
38+ accessory={
39+ touched && !active && error ? (
40+ <AccessoryIcon.Failure />
41+ ) : validating || submitting ? (
42+ <AccessoryIcon.Pending />
43+ ) : (
44+ undefined
45+ )
46+ }
47+ {...rest}>
48+ {label}
49+ </UploadButton>
50+ );
51+}
src/form/ValidationPauser.jsnew file+27
@@ -0,0 +1,27 @@
1+import { useEffect } from 'react';
2+import { useForm } from 'react-final-form';
3+
4+/**
5+ * pauses validation when it becomes unmounted
6+ *
7+ * NOTE: this is useful because for some reason, final-form will
8+ * re-run validation when a field is unregistered (even post submission !?),
9+ * which react-final-form does on unmount. So when we redirect away from
10+ * a page with a form on it, all of the validation is re-triggered.
11+ *
12+ * Normally, for boring sync-validation-only situations, this is ok, but for
13+ * long-running validators like ours (deriving seeds, checking chain, etc)
14+ * having them re-triggered when a form leaves the page is hilariously bad.
15+ *
16+ * So here we disable validation when unmounting, saving ourselves
17+ * from the footgun.
18+ *
19+ * see: https://github.com/final-form/react-final-form/issues/408
20+ */
21+export default function ValidationPauser() {
22+ const form = useForm();
23+
24+ useEffect(() => () => form.pauseValidation(), [form]);
25+
26+ return null;
27+}
src/indigo-react/components/AccessoryIcon.js+1−1
@@ -4,7 +4,7 @@ import Flex from './Flex';
44
55const PENDING_ACCESSORY = '⋯';
66const SUCCESS_ACCESSORY = '✓';
7−const FAILURE_ACCESSORY = '✗';
7+const FAILURE_ACCESSORY = '!';
88
99function AccessoryIcon({ ...props }) {
1010 return (
src/indigo-react/components/CheckboxInput.js+2−11
@@ -15,7 +15,7 @@ export default function CheckboxInput({
1515 const {
1616 input,
1717 meta: { submitting },
18− } = useField(name);
18+ } = useField(name, { type: 'checkbox' });
1919
2020 disabled = disabled || submitting;
2121
@@ -34,16 +34,7 @@ export default function CheckboxInput({
3434 }),
3535 }}>
3636 {/* we totally hide the checkbox itself */}
37− <Flex.Item
38− as="input"
39− type="checkbox"
40− className="super-hidden"
41− id={name}
42− {...input}
43− // TODO: this seems hacky... final-form should have a standard way to do this
44− checked={input.value}
45− onClick={e => input.onChange(!input.value)}
46− />
37+ <Flex.Item as="input" className="super-hidden" id={name} {...input} />
4738 {/* and then display a prettier one in its stead */}
4839 <Flex.Item
4940 flex
src/indigo-react/components/Input.js+9−24
@@ -1,4 +1,4 @@
1−import React, { useCallback } from 'react';
1+import React from 'react';
22import cn from 'classnames';
33
44import Flex from './Flex';
@@ -26,31 +26,17 @@ export default function Input({
2626}) {
2727 const {
2828 input,
29− meta: {
30− active,
31− data,
32− dirty,
33− error,
34− modified,
35− pristine,
36− submitError,
37− submitFailed,
38− submitSucceeded,
39− submitting,
40− touched,
41− valid,
42− validating,
43− visited,
44− },
29+ meta: { active, error, submitting, touched, valid },
4530 } = useField(name, config);
4631
4732 disabled = disabled || submitting;
4833
4934 // notify parent of enter keypress iff not disabled and passing
50− const onKeyPress = useCallback(
51− e => !disabled && valid && e.key === 'Enter' && onEnter && onEnter(),
52− [disabled, valid] // eslint-disable-line react-hooks/exhaustive-deps
53− );
35+ // TODO: integrate this into react-final-form submission
36+ // const onKeyPress = useCallback(
37+ // e => !disabled && valid && e.key === 'Enter' && onEnter && onEnter(),
38+ // [disabled, valid] // eslint-disable-line react-hooks/exhaustive-deps
39+ // );
5440
5541 return (
5642 <Flex
@@ -75,7 +61,6 @@ export default function Input({
7561 <Flex.Item
7662 flex
7763 as={type === 'textarea' ? 'textarea' : 'input'}
78− type={type === 'textarea' ? undefined : type}
7964 {...rest}
8065 // NOTE: 24px = 12px * 2 (from p3 styling)
8166 style={type === 'textarea' ? { minHeight: 'calc(1rem + 24px)' } : {}}
@@ -99,8 +84,8 @@ export default function Input({
9984 )}
10085 id={name}
10186 name={name}
102− onKeyPress={onKeyPress}
10387 {...input}
88+ type={type === 'textarea' ? undefined : type}
10489 />
10590 {accessory && (
10691 <div
@@ -116,7 +101,7 @@ export default function Input({
116101 </div>
117102 )}
118103 </Flex.Item>
119− {touched && error && (
104+ {touched && !active && error && (
120105 <Flex.Item as={ErrorText} className="mv1">
121106 {error}
122107 </Flex.Item>
src/indigo-react/components/SelectInput.js+27−47
@@ -5,65 +5,47 @@ import Flex from './Flex';
55import { ErrorText } from './Typography';
66import useOnClickOutside from 'indigo-react/lib/useOnClickOutside';
77import AccessoryIcon from './AccessoryIcon';
8+import { useField } from 'react-final-form';
89
910// NOTE: if we really care about accessibility, we should pull in a dependency
1011export default function SelectInput({
11− // visuals
1212 name,
1313 label,
14+ placeholder,
1415 className,
15− accessory,
1616 mono = false,
17− placeholder,
18−
19− // callbacks
20− onEnter,
21−
22− // state from hook
23− focused,
24− pass,
25− syncPass,
26− visiblyPassed,
27− error,
28− hintError,
29− data,
30− bind,
31− autoFocus,
17+ options = [],
3218 disabled,
33− options,
34− touched,
19+}) {
20+ const {
21+ input,
22+ meta: { active, error, submitting, touched, valid },
23+ } = useField(name, {
24+ type: 'select',
25+ });
3526
36− // ignored
37− initialValue,
38− validators,
39− transformers,
27+ disabled = disabled || submitting;
4028
41− // extra
42− ...rest
43−}) {
4429 const [isOpen, setIsOpen] = useState(false);
4530 const ref = useRef();
4631
4732 // close select on outside clicks
4833 useOnClickOutside(ref, useCallback(() => setIsOpen(false), [setIsOpen]));
4934
50− const toggleOpen = useCallback(() => setIsOpen(isOpen => !isOpen), [
51− setIsOpen,
52− ]);
35+ const toggleOpen = useCallback(() => {
36+ input.onFocus();
37+ setIsOpen(isOpen => !isOpen);
38+ }, [input]);
5339
5440 const onChange = value => {
55− // TODO: provide setValue here?
5641 // construct a pseudo event that sets the value correctly
57− bind.onChange({ target: { value } });
42+ input.onChange({ target: { value } });
43+ input.onBlur();
5844 setIsOpen(false);
5945 };
6046
61− // redefine accessory because we still want to ignore it from the ..rest above
62− accessory = (
63− <AccessoryIcon className="gray4">{isOpen ? '▲' : '▼'}</AccessoryIcon>
64− );
65−
66− const text = options.find(o => o.value === data).text;
47+ console.log(input);
48+ const text = options.find(o => o.value === input.value).text;
6749
6850 return (
6951 <Flex
@@ -81,7 +63,7 @@ export default function SelectInput({
8163 </Flex.Item>
8264 <Flex.Item as={Flex} row className="rel clickable" onClick={toggleOpen}>
8365 <Flex.Item
84− // as="select"
66+ flex
8567 className={cn(
8668 'b b1 p3 outline-none',
8769 { mono },
@@ -94,16 +76,14 @@ export default function SelectInput({
9476 black: !isOpen,
9577 },
9678 {
97− 'b-green3': visiblyPassed,
98− 'b-black': focused && !visiblyPassed,
99− 'b-yellow3': !focused && hintError,
100− 'b-gray3': !focused && !hintError && !visiblyPassed,
79+ 'b-green3': valid,
80+ 'b-black': !valid && active,
81+ 'b-yellow3': !valid && !active && touched && error,
82+ 'b-gray2': !valid && !active && !touched && !error,
10183 }
10284 )}
10385 id={name}
104− name={name}
105− {...bind}
106− flex>
86+ name={name}>
10787 {isOpen ? placeholder : text}
10888 </Flex.Item>
10989 <div
@@ -115,7 +95,7 @@ export default function SelectInput({
11595 width: '44px',
11696 overflow: 'hidden',
11797 }}>
118− {accessory}
98+ <AccessoryIcon className="gray4">{isOpen ? '▲' : '▼'}</AccessoryIcon>
11999 </div>
120100 {isOpen && (
121101 <Flex
@@ -142,7 +122,7 @@ export default function SelectInput({
142122 </Flex>
143123 )}
144124 </Flex.Item>
145− {error && (
125+ {touched && !active && error && (
146126 <Flex.Item as={ErrorText} className="mv1">
147127 {error}
148128 </Flex.Item>
src/indigo-react/components/ToggleInput.js+12−22
@@ -3,6 +3,7 @@ import cn from 'classnames';
33
44import Flex from './Flex';
55import LinkButton from './LinkButton';
6+import { useField } from 'react-final-form';
67
78export default function ToggleInput({
89 // visuals
@@ -11,32 +12,21 @@ export default function ToggleInput({
1112 inverseLabel,
1213 className,
1314
14− // callbacks
15− onEnter,
16−
17− // state from hook
18− focused,
19− pass,
20− syncPass,
21− visiblyPassed,
22− error,
23− hintError,
24− data,
25− bind,
26− autoFocus,
15+ //
2716 disabled,
28− touched,
29−
30− // ignored
31− initialValue,
32− validators,
33− transformers,
3417
3518 ...rest
3619}) {
20+ const {
21+ input,
22+ meta: { submitting },
23+ } = useField(name, { type: 'checkbox' });
24+
25+ disabled = disabled || submitting;
26+
3727 return (
3828 <Flex
39− className={cn(className)}
29+ className={className}
4030 row
4131 align="center"
4232 style={{
@@ -52,7 +42,7 @@ export default function ToggleInput({
5242 className={cn('super-hidden')}
5343 id={name}
5444 name={name}
55− {...bind}
45+ {...input}
5646 />
5747 {/* and then display a prettier one in its stead */}
5848 <Flex.Item
@@ -63,7 +53,7 @@ export default function ToggleInput({
6353 })}
6454 htmlFor={name}>
6555 <LinkButton disabled={disabled} className="f5">
66− {bind.checked ? inverseLabel : label}
56+ {input.checked ? inverseLabel : label}
6757 </LinkButton>
6858 </Flex.Item>
6959 </Flex>
src/lib/useInputs.js+2−4
@@ -214,10 +214,8 @@ export function useSelectInput({ initialValue, options, ...rest }) {
214214 type: 'select',
215215 validators: useMemo(
216216 () => [
217− validateOneOf(
218− options.map(option => option.value),
219− validateNotEmpty
220− ),
217+ validateOneOf(options.map(option => option.value)),
218+ validateNotEmpty,
221219 ],
222220 [options]
223221 ),
src/lib/validators.js+13
@@ -225,6 +225,19 @@ export const validateLength = l => m =>
225225 error: `Must be exactly ${l} characters.`,
226226 });
227227
228+export const validateHexLength = l => m =>
229+ simpleValidatorWrapper({
230+ prevMessage: m,
231+ validator: d => {
232+ try {
233+ return d.length === l + 2;
234+ } catch {
235+ return false;
236+ }
237+ },
238+ error: `Must be exactly ${l} hex characters.`,
239+ });
240+
228241export const validateMaximumLength = l => m =>
229242 simpleValidatorWrapper({
230243 prevMessage: m,
src/views/Activate/ActivateCode.js+34−13
@@ -1,4 +1,4 @@
1−import React, { useCallback, useRef } from 'react';
1+import React, { useCallback, useMemo, useRef } from 'react';
22import { Just, Nothing } from 'folktale/maybe';
33import * as azimuth from 'azimuth-js';
44import { Grid, H4 } from 'indigo-react';
@@ -24,7 +24,14 @@ import useHasDisclaimed from 'lib/useHasDisclaimed';
2424
2525import BridgeForm from 'form/BridgeForm';
2626import SubmitButton from 'form/SubmitButton';
27−import { TicketInput } from 'form/Inputs';
27+import {
28+ TicketInput,
29+ hasErrors,
30+ composeValidator,
31+ buildTicketValidator,
32+} from 'form/Inputs';
33+import FormError from 'form/FormError';
34+import { FORM_ERROR } from 'final-form';
2835
2936export default function ActivateCode() {
3037 const history = useHistory();
@@ -57,8 +64,14 @@ export default function ActivateCode() {
5764 // validate should be a pure function but we don't want to have to recompute
5865 // all of this information on submit, so cache the invite wallet and avoid
5966 // re-renders that may trigger re-validations (causing infinite loop)
60− const validate = useCallback(
61− async ticket => {
67+ const validateForm = useCallback(
68+ async ({ ticket }, errors) => {
69+ if (hasErrors(errors)) {
70+ return errors;
71+ }
72+
73+ console.log('FUCK — deriving seed');
74+
6275 await timeout(100); // allow the ui changes to flush before we lag it out
6376
6477 const _contracts = need.contracts(contracts);
@@ -93,26 +106,32 @@ export default function ActivateCode() {
93106 setDerivedPoint(Just(point));
94107 cachedPoint.current = point;
95108 } else {
96− return (
97− 'Invite code has no claimable point.\n' +
98− 'Check your invite code and try again?'
99− );
109+ return {
110+ [FORM_ERROR]:
111+ 'Invite code has no claimable point.\n' +
112+ 'Check your invite code and try again?',
113+ };
100114 }
101115 },
102116 [contracts, setDerivedPoint]
103117 );
104118
119+ const validate = useMemo(
120+ () => composeValidator({ ticket: buildTicketValidator() }, validateForm),
121+ [validateForm]
122+ );
123+
105124 // set our state on submission
106125 const onSubmit = useCallback(
107126 async values => {
108127 setInviteWallet(cachedInviteWallet.current);
109128 setDerivedWallet(Just(await generateWallet(cachedPoint)));
110−
111− goToPassport();
112129 },
113− [goToPassport, setDerivedWallet, setInviteWallet]
130+ [setDerivedWallet, setInviteWallet]
114131 );
115132
133+ const afterSubmit = useCallback(async () => goToPassport(), [goToPassport]);
134+
116135 return (
117136 <View inset>
118137 <Grid>
@@ -121,7 +140,9 @@ export default function ActivateCode() {
121140 Activate
122141 </Grid.Item>
123142 <BridgeForm
143+ validate={validate}
124144 onSubmit={onSubmit}
145+ afterSubmit={afterSubmit}
125146 initialValues={{ ticket: impliedTicket || '' }}>
126147 {({ validating, submitting, handleSubmit }) => (
127148 <>
@@ -130,10 +151,10 @@ export default function ActivateCode() {
130151 as={TicketInput}
131152 name="ticket"
132153 label="Activation Code"
133− validate={validate}
134− autoFocus
135154 />
136155
156+ <Grid.Item full as={FormError} />
157+
137158 <Grid.Item
138159 full
139160 as={SubmitButton}
src/views/Activate/PassportVerify.js+16−5
@@ -7,11 +7,16 @@ import { validateExactly } from 'lib/validators';
77import { isDevelopment } from 'lib/flags';
88
99import SubmitButton from 'form/SubmitButton';
10−import { TicketInput } from 'form/Inputs';
10+import {
11+ TicketInput,
12+ composeValidator,
13+ buildTicketValidator,
14+} from 'form/Inputs';
1115import BridgeForm from 'form/BridgeForm';
1216
1317import { useActivateFlow } from './ActivateFlow';
1418import PassportView from './PassportView';
19+import FormError from 'form/FormError';
1520
1621const STUB_VERIFY_TICKET = isDevelopment;
1722
@@ -21,8 +26,13 @@ export default function PassportVerify({ className }) {
2126 const goToTransfer = useCallback(() => push(names.TRANSFER), [push, names]);
2227
2328 const { ticket } = need.wallet(derivedWallet);
24− const validators = useMemo(
25− () => [validateExactly(ticket, 'Does not match expected master ticket.')],
29+ const validate = useMemo(
30+ () =>
31+ composeValidator({
32+ ticket: buildTicketValidator([
33+ validateExactly(ticket, 'Does not match expected master ticket.'),
34+ ]),
35+ }),
2636 [ticket]
2737 );
2838
@@ -37,6 +47,7 @@ export default function PassportVerify({ className }) {
3747 Open it and enter the 4 word phrase below (with hyphens).
3848 </Grid.Item>
3949 <BridgeForm
50+ validate={validate}
4051 onSubmit={onSubmit}
4152 initialValues={{
4253 ticket: STUB_VERIFY_TICKET ? ticket : undefined,
@@ -48,10 +59,10 @@ export default function PassportVerify({ className }) {
4859 as={TicketInput}
4960 name="ticket"
5061 label="Master Ticket"
51− validators={validators}
52− autoFocus
5362 />
5463
64+ <Grid.Item full as={FormError} />
65+
5566 <Grid.Item
5667 full
5768 className="mt3"
src/views/Login.js+4−18
@@ -68,8 +68,6 @@ export default function Login() {
6868 const { wallet, walletType } = useWallet();
6969 const { pointCursor, setPointCursor } = usePointCursor();
7070
71− const [deducing, setDeducing] = useState(false);
72−
7371 // inputs
7472 const [currentTab, setCurrentTab] = useState(
7573 walletTypeToViewName(walletType)
@@ -89,12 +87,10 @@ export default function Login() {
8987 push(names.POINT);
9088 }, [goToPoints, push, names]);
9189
92− const doContinue = useCallback(async () => {
90+ const goHome = useCallback(async () => {
9391 const _wallet = need.wallet(wallet);
9492 const _contracts = need.contracts(contracts);
9593
96− setDeducing(true);
97−
9894 let deduced = pointCursor;
9995 // if no point cursor set by login logic, try to deduce it
10096 if (Nothing.hasInstance(deduced)) {
@@ -107,8 +103,6 @@ export default function Login() {
107103 }
108104 }
109105
110− setDeducing(false);
111−
112106 // if we have a deduced point or one in the global context,
113107 // navigate to that specific point, otherwise navigate to list of points
114108 if (Just.hasInstance(deduced)) {
@@ -131,22 +125,14 @@ export default function Login() {
131125 full
132126 as={Tabs}
133127 className="mt1"
128+ // Tabs
134129 views={VIEWS}
135130 options={OPTIONS}
136131 currentTab={currentTab}
137132 onTabChange={setCurrentTab}
133+ // Tab extra
134+ goHome={goHome}
138135 />
139−
140− <Grid.Item
141− full
142− as={ForwardButton}
143− solid
144− className="mt2"
145− loading={deducing}
146− disabled={Nothing.hasInstance(wallet) || deducing}
147− onClick={doContinue}>
148− Continue
149− </Grid.Item>
150136 </Grid>
151137
152138 <Footer>
src/views/Login/Advanced.js+2−2
@@ -20,7 +20,7 @@ const OPTIONS = [
2020 { text: 'Ethereum Keystore', value: NAMES.KEYSTORE },
2121];
2222
23−export default function Advanced({ loginCompleted, className }) {
23+export default function Advanced({ className, ...rest }) {
2424 const [currentTab, setCurrentTab] = useState(undefined);
2525
2626 return (
@@ -31,7 +31,7 @@ export default function Advanced({ loginCompleted, className }) {
3131 currentTab={currentTab}
3232 onTabChange={setCurrentTab}
3333 //
34− loginCompleted={loginCompleted}
34+ {...rest}
3535 />
3636 );
3737}
src/views/Login/ContinueButton.jsnew file+26
@@ -0,0 +1,26 @@
1+import React from 'react';
2+import cn from 'classnames';
3+import { useFormState } from 'react-final-form';
4+
5+import { ForwardButton } from 'components/Buttons';
6+import Blinky from 'components/Blinky';
7+
8+export default function ContinueButton({ className, children, handleSubmit }) {
9+ const { valid, validating, submitting } = useFormState({
10+ subscription: { valid: true, validating: true, submitting: true },
11+ });
12+
13+ const loading = validating || submitting;
14+
15+ return (
16+ <ForwardButton
17+ solid
18+ className={cn('mt2', className)}
19+ loading={validating}
20+ disabled={!valid || loading}
21+ accessory={loading ? <Blinky /> : undefined}
22+ onClick={handleSubmit}>
23+ {children || 'Continue'}
24+ </ForwardButton>
25+ );
26+}
src/views/Login/Hardware.js+2−2
@@ -20,7 +20,7 @@ const OPTIONS = [
2020 { text: 'Trezor', value: NAMES.TREZOR },
2121];
2222
23−export default function Hardware({ loginCompleted, className }) {
23+export default function Hardware({ className, ...rest }) {
2424 const [currentTab, setCurrentTab] = useState(undefined);
2525
2626 return (
@@ -31,7 +31,7 @@ export default function Hardware({ loginCompleted, className }) {
3131 currentTab={currentTab}
3232 onTabChange={setCurrentTab}
3333 //
34− loginCompleted={loginCompleted}
34+ {...rest}
3535 />
3636 );
3737}
src/views/Login/Keystore.js+66−73
@@ -1,17 +1,24 @@
1−import React, { useState } from 'react';
2−import { Just, Nothing } from 'folktale/maybe';
3−import { P, Grid, Input, ErrorText } from 'indigo-react';
1+import React, { useCallback, useMemo } from 'react';
2+import { Just } from 'folktale/maybe';
3+import { P, Grid } from 'indigo-react';
44import * as keythereum from 'keythereum';
55
66import { useWallet } from 'store/wallet';
77
8−import { usePassphraseInput } from 'lib/useInputs';
9−import * as need from 'lib/need';
108import { EthereumWallet, WALLET_TYPES } from 'lib/wallet';
119import useLoginView from 'lib/useLoginView';
1210
13−import { ForwardButton } from 'components/Buttons';
14−import UploadButton from 'components/UploadButton';
11+import {
12+ composeValidator,
13+ buildPassphraseValidator,
14+ PassphraseInput,
15+ buildUploadValidator,
16+} from 'form/Inputs';
17+import UploadInput from 'form/UploadInput';
18+import ContinueButton from './ContinueButton';
19+import BridgeForm from 'form/BridgeForm';
20+import { FORM_ERROR } from 'final-form';
21+import FormError from 'form/FormError';
1522
1623export default function Keystore({ className }) {
1724 useLoginView(WALLET_TYPES.KEYSTORE);
@@ -19,52 +26,33 @@ export default function Keystore({ className }) {
1926 // globals
2027 const { setWallet } = useWallet();
2128
22− const [error, setError] = useState();
23− // inputs
24− // keystore: Maybe<String>
25− const [keystore, setKeystore] = useState(Nothing());
26− const [passphraseInput, { data: passphrase }] = usePassphraseInput({
27− name: 'password',
28− label: 'Keystore password',
29− autoFocus: true,
30− });
31−
32− const constructWallet = () => {
33− try {
34− const text = need.keystore(keystore);
35−
36− const json = JSON.parse(text);
37− const privateKey = keythereum.recover(passphrase, json);
38−
39− const newWallet = new EthereumWallet(privateKey);
40− setError();
41− setWallet(Just(newWallet));
42− } catch (err) {
43− setError(
44− "Couldn't decrypt wallet. You may have entered an incorrect password."
45− );
46− setWallet(Nothing());
47− }
48− };
49−
50− const handleKeystoreUpload = element => {
51− const file = element.files.item(0);
52− const reader = new FileReader();
53−
54− reader.onload = e => {
55− const keystore = e.target.result;
56− setKeystore(Just(keystore));
57− };
58−
59− const failure = _ => {
60− setError('There was a problem uploading your Keystore file');
61− };
62−
63− reader.onerror = failure;
64− reader.onabort = failure;
29+ const validate = useMemo(
30+ () =>
31+ composeValidator({
32+ passphrase: buildPassphraseValidator(),
33+ keystore: buildUploadValidator(),
34+ }),
35+ []
36+ );
6537
66− reader.readAsText(file);
67− };
38+ const onSubmit = useCallback(
39+ async values => {
40+ try {
41+ const json = JSON.parse(values.keystore);
42+ const privateKey = keythereum.recover(values.passphrase, json);
43+
44+ const wallet = new EthereumWallet(privateKey);
45+ setWallet(Just(wallet));
46+ } catch (error) {
47+ console.error(error);
48+ return {
49+ [FORM_ERROR]:
50+ "Couldn't decrypt wallet. You may have entered an incorrect password.",
51+ };
52+ }
53+ },
54+ [setWallet]
55+ );
6856
6957 return (
7058 <Grid className={className}>
@@ -73,27 +61,32 @@ export default function Keystore({ className }) {
7361 encrypted with a password, you'll also need to enter that below.
7462 </Grid.Item>
7563
76− <Grid.Item full as={UploadButton} onChange={handleKeystoreUpload}>
77− Upload Keystore file
78− </Grid.Item>
79−
80− {error && (
81− <Grid.Item full as={ErrorText} className="mt1">
82− {error}
83− </Grid.Item>
84− )}
85−
86− <Grid.Item full as={Input} className="mt3" {...passphraseInput} />
87−
88− <Grid.Item
89− full
90− as={ForwardButton}
91− solid
92− className="mt3"
93− disabled={Nothing.hasInstance(keystore)}
94− onClick={constructWallet}>
95− Decrypt
96− </Grid.Item>
64+ <BridgeForm validate={validate} onSubmit={onSubmit}>
65+ {({ handleSubmit }) => (
66+ <>
67+ <Grid.Item
68+ full
69+ as={UploadInput}
70+ name="keystore"
71+ label="Upload Keystore file"
72+ />
73+
74+ <Grid.Item
75+ full
76+ as={PassphraseInput}
77+ className="mt3"
78+ name="passphrase"
79+ label="Passphrase"
80+ />
81+
82+ <Grid.Item full as={FormError} />
83+
84+ <Grid.Item full as={ContinueButton} handleSubmit={handleSubmit}>
85+ Decrypt
86+ </Grid.Item>
87+ </>
88+ )}
89+ </BridgeForm>
9790 </Grid>
9891 );
9992}
src/views/Login/Ledger.js+115−102
@@ -1,4 +1,4 @@
1−import React, { useCallback, useEffect } from 'react';
1+import React, { useCallback, useMemo } from 'react';
22import cn from 'classnames';
33import { Just, Nothing } from 'folktale/maybe';
44import {
@@ -18,14 +18,6 @@ import * as secp256k1 from 'secp256k1';
1818
1919import { useWallet } from 'store/wallet';
2020
21−import { ForwardButton } from 'components/Buttons';
22−
23−import {
24− useCheckboxInput,
25− useHdPathInput,
26− useSelectInput,
27−} from 'lib/useInputs';
28−
2921import {
3022 LEDGER_LIVE_PATH,
3123 LEDGER_LEGACY_PATH,
@@ -35,6 +27,16 @@ import {
3527import { WALLET_TYPES } from 'lib/wallet';
3628import useLoginView from 'lib/useLoginView';
3729import useBreakpoints from 'lib/useBreakpoints';
30+import {
31+ buildCheckboxValidator,
32+ buildHdPathValidator,
33+ composeValidator,
34+ buildSelectValidator,
35+} from 'form/Inputs';
36+import BridgeForm from 'form/BridgeForm';
37+import FormError from 'form/FormError';
38+import ContinueButton from './ContinueButton';
39+import Condition from 'form/Condition';
3840
3941const PATH_OPTIONS = [
4042 { text: 'Ledger Live', value: LEDGER_LIVE_PATH },
@@ -46,74 +48,60 @@ const ACCOUNT_OPTIONS = times(20, i => ({
4648 value: i,
4749}));
4850
49−export default function Ledger({ className }) {
51+export default function Ledger({ className, goHome }) {
5052 useLoginView(WALLET_TYPES.LEDGER);
5153
5254 const { setWallet, setWalletHdPath } = useWallet();
5355
54− // derivation path input
55− const [derivationPathInput, { data: basePathPattern }] = useSelectInput({
56− name: 'derivationpath',
57− label: 'Derivation Path',
58− placeholder: 'Choose path pattern...',
59− options: PATH_OPTIONS,
60− });
61−
62− // account input
63− const [accountInput, { data: accountIndex }] = useSelectInput({
64− name: 'account',
65− label: 'Account',
66− placeholder: 'Choose account...',
67− options: ACCOUNT_OPTIONS,
68− });
69−
70− // custom toggle
71− const [customPathInput, { data: useCustomPath }] = useCheckboxInput({
72− name: 'customPath',
73− label: 'Custom HD Path',
74− autoComplete: 'off',
75− initialValue: false,
76− });
77−
78− // hd path input
79− const [
80− hdPathInput,
81− { data: hdPath },
82− { setValue: setHdPath },
83− ] = useHdPathInput({
84− name: 'hdpath',
85− label: 'HD Path',
86− initialValue: basePathPattern.replace(/x/g, 0),
87− });
88−
89− const pollDevice = useCallback(async () => {
90− const transport = await Transport.create();
91− const eth = new Eth(transport);
92− const path = chopHdPrefix(hdPath);
93−
94− try {
95− const info = await eth.getAddress(path, false, true);
96− const publicKey = Buffer.from(info.publicKey, 'hex');
97− const chainCode = Buffer.from(info.chainCode, 'hex');
98− const pub = secp256k1.publicKeyConvert(publicKey, true);
99− const hd = bip32.fromPublicKey(pub, chainCode);
100− setWallet(Just(hd));
101− setWalletHdPath(addHdPrefix(hdPath));
102− } catch (error) {
103− console.error(error);
104− setWallet(Nothing());
56+ const validate = useMemo(
57+ () =>
58+ composeValidator({
59+ useCustomPath: buildCheckboxValidator(),
60+ derivationpath: buildSelectValidator(PATH_OPTIONS),
61+ account: buildSelectValidator(ACCOUNT_OPTIONS),
62+ hdpath: buildHdPathValidator(),
63+ }),
64+ []
65+ );
66+
67+ const onSubmit = useCallback(
68+ async values => {
69+ const transport = await Transport.create();
70+ const eth = new Eth(transport);
71+ const path = chopHdPrefix(values.hdpath);
72+
73+ try {
74+ const info = await eth.getAddress(path, false, true);
75+ const publicKey = Buffer.from(info.publicKey, 'hex');
76+ const chainCode = Buffer.from(info.chainCode, 'hex');
77+ const pub = secp256k1.publicKeyConvert(publicKey, true);
78+ const hd = bip32.fromPublicKey(pub, chainCode);
79+ setWallet(Just(hd));
80+ setWalletHdPath(addHdPrefix(values.hdpath));
81+ } catch (error) {
82+ console.error(error);
83+ setWallet(Nothing());
84+ }
85+
86+ return await goHome();
87+ },
88+ [goHome, setWallet, setWalletHdPath]
89+ );
90+
91+ const onValues = useCallback(({ valid, values, form }) => {
92+ if (!valid) {
93+ return;
10594 }
106− }, [hdPath, setWallet, setWalletHdPath]);
107−
108− // when the base path pattern or the account index changes
109− // update the hd path in our input
110− useEffect(() => {
111− if (useCustomPath) {
112− // updated by useForm
113− } else {
114− setHdPath(basePathPattern.replace(/x/g, accountIndex));
95+
96+ // when the base path pattern or the account index changes
97+ // update the hd path in our input
98+ if (!values.useCustomPath) {
99+ form.change(
100+ 'hdpath',
101+ values.derivationpath.replace(/x/g, values.account)
102+ );
115103 }
116− }, [useCustomPath, setHdPath, basePathPattern, accountIndex]);
104+ }, []);
117105
118106 const full = useBreakpoints([true, true, false]);
119107 const half = useBreakpoints([false, false, true]);
@@ -170,37 +158,62 @@ export default function Ledger({ className }) {
170158 enable the "contract data" option.
171159 </Grid.Item>
172160
173− {useCustomPath && <Grid.Item full as={Input} {...hdPathInput} />}
174−
175− {!useCustomPath && (
176− <>
177− <Grid.Item
178− full={full}
179− half={half && 1}
180− as={SelectInput}
181− className={cn({ pr1: half })}
182− {...derivationPathInput}
183− />
184− <Grid.Item
185− full={full}
186− half={half && 2}
187− as={SelectInput}
188− className={cn({ pl1: half })}
189− {...accountInput}
190− />
191− </>
192− )}
193−
194− <Grid.Item full as={CheckboxInput} className="mv3" {...customPathInput} />
195−
196− <Grid.Item
197− full
198− as={ForwardButton}
199− solid
200− className="mt2"
201− onClick={pollDevice}>
202− Authenticate
203− </Grid.Item>
161+ <BridgeForm
162+ validate={validate}
163+ onValues={onValues}
164+ onSubmit={onSubmit}
165+ initialValues={{
166+ useCustomPath: false,
167+ hdpath: PATH_OPTIONS[0].value.replace(/x/g, ACCOUNT_OPTIONS[0].value),
168+ derivationpath: PATH_OPTIONS[0].value,
169+ account: ACCOUNT_OPTIONS[0].value,
170+ }}>
171+ {({ handleSubmit }) => (
172+ <>
173+ <Condition when="useCustomPath" is={true}>
174+ <Grid.Item full as={Input} name="hdpath" label="HD Path" />
175+ </Condition>
176+
177+ <Condition when="useCustomPath" is={false}>
178+ <Grid.Item
179+ full={full}
180+ half={half && 1}
181+ as={SelectInput}
182+ className={cn({ pr1: half })}
183+ name="derivationpath"
184+ label="Derivation Path"
185+ placeholder="Choose path pattern..."
186+ options={PATH_OPTIONS}
187+ />
188+
189+ <Grid.Item
190+ full={full}
191+ half={half && 2}
192+ as={SelectInput}
193+ className={cn({ pl1: half })}
194+ name="account"
195+ label="Account"
196+ placeholder="Choose account..."
197+ options={ACCOUNT_OPTIONS}
198+ />
199+ </Condition>
200+
201+ <Grid.Item
202+ full
203+ as={CheckboxInput}
204+ className="mv3"
205+ name="useCustomPath"
206+ label="Custom HD Path"
207+ />
208+
209+ <Grid.Item full as={FormError} />
210+
211+ <Grid.Item full as={ContinueButton} handleSubmit={handleSubmit}>
212+ Authenticate
213+ </Grid.Item>
214+ </>
215+ )}
216+ </BridgeForm>
204217 </>
205218 );
206219
src/views/Login/Mnemonic.js+36−24
@@ -1,4 +1,4 @@
1−import React, { useCallback } from 'react';
1+import React, { useCallback, useMemo } from 'react';
22import cn from 'classnames';
33import { Just, Nothing } from 'folktale/maybe';
44import { Grid, CheckboxInput } from 'indigo-react';
@@ -7,11 +7,22 @@ import { useWallet } from 'store/wallet';
77
88import { walletFromMnemonic, WALLET_TYPES } from 'lib/wallet';
99import useLoginView from 'lib/useLoginView';
10−import { MnemonicInput, HdPathInput, PassphraseInput } from 'form/Inputs';
10+import {
11+ MnemonicInput,
12+ HdPathInput,
13+ PassphraseInput,
14+ composeValidator,
15+ buildMnemonicValidator,
16+ buildCheckboxValidator,
17+ buildPassphraseValidator,
18+ buildHdPathValidator,
19+} from 'form/Inputs';
1120import BridgeForm from 'form/BridgeForm';
1221import Condition from 'form/Condition';
22+import FormError from 'form/FormError';
23+import ContinueButton from './ContinueButton';
1324
14−export default function Mnemonic({ className }) {
25+export default function Mnemonic({ className, goHome }) {
1526 useLoginView(WALLET_TYPES.MNEMONIC);
1627
1728 const {
@@ -21,6 +32,17 @@ export default function Mnemonic({ className }) {
2132 setWalletHdPath,
2233 } = useWallet();
2334
35+ const validate = useMemo(
36+ () =>
37+ composeValidator({
38+ useAdvanced: buildCheckboxValidator(),
39+ mnemonic: buildMnemonicValidator(),
40+ passphrase: buildPassphraseValidator(),
41+ hdpath: buildHdPathValidator(),
42+ }),
43+ []
44+ );
45+
2446 // when the properties change, re-derive wallet and set global state
2547 const onValues = useCallback(
2648 ({ valid, values }) => {
@@ -41,10 +63,11 @@ export default function Mnemonic({ className }) {
4163 return (
4264 <Grid className={cn('mt4', className)}>
4365 <BridgeForm
66+ validate={validate}
4467 onValues={onValues}
45− onSubmit={() => undefined}
46− initialValues={{ hdpath: walletHdPath }}>
47− {() => (
68+ onSubmit={goHome}
69+ initialValues={{ hdpath: walletHdPath, useAdvanced: false }}>
70+ {({ handleSubmit }) => (
4871 <>
4972 <Grid.Item
5073 full
@@ -53,7 +76,7 @@ export default function Mnemonic({ className }) {
5376 label="BIP39 Mnemonic"
5477 />
5578
56− <Condition when="advanced" is={true}>
79+ <Condition when="useAdvanced" is={true}>
5780 <Grid.Item
5881 full
5982 as={PassphraseInput}
@@ -61,33 +84,22 @@ export default function Mnemonic({ className }) {
6184 label="Passphrase"
6285 />
6386
64− <Grid.Item
65− full
66− as={HdPathInput}
67− name="hdpath"
68− label="HD Path"
69− autoFocus
70− />
87+ <Grid.Item full as={HdPathInput} name="hdpath" label="HD Path" />
7188 </Condition>
7289
7390 <Grid.Item
7491 full
7592 as={CheckboxInput}
76− name="advanced"
93+ name="useAdvanced"
7794 label="Passphrase & HD Path"
7895 />
96+
97+ <Grid.Item full as={FormError} />
98+
99+ <Grid.Item full as={ContinueButton} handleSubmit={handleSubmit} />
79100 </>
80101 )}
81102 </BridgeForm>
82−
83− {/* {useAdvanced && (
84− <>
85− <Grid.Item full as={Input} {...passphraseInput} />
86− <Grid.Item full as={Input} {...hdPathInput} />
87− </>
88− )}
89−
90− <Grid.Item as={CheckboxInput} {...advancedInput} full /> */}
91103 </Grid>
92104 );
93105}
src/views/Login/PrivateKey.js+44−20
@@ -1,38 +1,62 @@
1−import React, { useEffect } from 'react';
1+import React, { useCallback, useMemo } from 'react';
22import { Just, Nothing } from 'folktale/maybe';
3−import { Grid, Input } from 'indigo-react';
3+import { Grid } from 'indigo-react';
44
55import { useWallet } from 'store/wallet';
66
7−import { useHexInput } from 'lib/useInputs';
87import { EthereumWallet, WALLET_TYPES, stripHexPrefix } from 'lib/wallet';
98import useLoginView from 'lib/useLoginView';
9+import FormError from 'form/FormError';
10+import ContinueButton from './ContinueButton';
11+import BridgeForm from 'form/BridgeForm';
12+import {
13+ buildHexValidator,
14+ composeValidator,
15+ PrivateKeyInput,
16+} from 'form/Inputs';
1017
11−export default function PrivateKey({ className }) {
18+export default function PrivateKey({ className, goHome }) {
1219 useLoginView(WALLET_TYPES.PRIVATE_KEY);
1320
1421 const { setWallet } = useWallet();
1522
16− const [privateKeyInput, { pass, data: privateKey }] = useHexInput({
17− length: 64,
18− name: 'privateKey',
19− label: 'Private key',
20− autoFocus: true,
21− });
23+ const validate = useMemo(
24+ () =>
25+ composeValidator({
26+ privatekey: buildHexValidator(64),
27+ }),
28+ []
29+ );
2230
23− useEffect(() => {
24− if (pass) {
25− const sec = Buffer.from(stripHexPrefix(privateKey), 'hex');
26− const newWallet = new EthereumWallet(sec);
27− setWallet(Just(newWallet));
28− } else {
29− setWallet(Nothing());
30− }
31− }, [pass, privateKey, setWallet]);
31+ const onValues = useCallback(
32+ ({ valid, values }) => {
33+ if (valid) {
34+ const sec = Buffer.from(stripHexPrefix(values.privatekey), 'hex');
35+ const newWallet = new EthereumWallet(sec);
36+ setWallet(Just(newWallet));
37+ } else {
38+ setWallet(Nothing());
39+ }
40+ },
41+ [setWallet]
42+ );
3243
3344 return (
3445 <Grid className={className}>
35− <Grid.Item full as={Input} {...privateKeyInput} />
46+ <BridgeForm validate={validate} onValues={onValues} onSubmit={goHome}>
47+ {({ handleSubmit }) => (
48+ <>
49+ <Grid.Item
50+ full
51+ as={PrivateKeyInput}
52+ name="privatekey"
53+ label="Private key"
54+ />
55+ <Grid.Item full as={FormError} />
56+ <Grid.Item full as={ContinueButton} handleSubmit={handleSubmit} />
57+ </>
58+ )}
59+ </BridgeForm>
3660 </Grid>
3761 );
3862}
src/views/Login/Ticket.js+181−159
@@ -1,28 +1,37 @@
1+import React, { useCallback, useMemo, useRef } from 'react';
12import { Just, Nothing } from 'folktale/maybe';
23import cn from 'classnames';
3−import React, { useCallback, useState, useEffect } from 'react';
44import * as azimuth from 'azimuth-js';
5−import * as ob from 'urbit-ob';
65import * as kg from 'urbit-key-generation/dist/index';
7−import { Input, Grid, CheckboxInput } from 'indigo-react';
6+import { Grid, CheckboxInput } from 'indigo-react';
7+import { FORM_ERROR } from 'final-form';
88
99import { useNetwork } from 'store/network';
1010import { useWallet } from 'store/wallet';
1111import { usePointCursor } from 'store/pointCursor';
1212
13−import {
14− usePointInput,
15− useTicketInput,
16− usePassphraseInput,
17− useCheckboxInput,
18−} from 'lib/useInputs';
1913import * as need from 'lib/need';
2014import { WALLET_TYPES, urbitWalletFromTicket } from 'lib/wallet';
2115import useImpliedPoint from 'lib/useImpliedPoint';
2216import useLoginView from 'lib/useLoginView';
2317import patp2dec from 'lib/patp2dec';
24−
25−export default function Ticket({ className }) {
18+import BridgeForm from 'form/BridgeForm';
19+import Condition from 'form/Condition';
20+import {
21+ TicketInput,
22+ PassphraseInput,
23+ PointInput,
24+ composeValidator,
25+ buildCheckboxValidator,
26+ buildTicketValidator,
27+ buildPassphraseValidator,
28+ buildPointValidator,
29+} from 'form/Inputs';
30+import timeout from 'lib/timeout';
31+import FormError from 'form/FormError';
32+import ContinueButton from './ContinueButton';
33+
34+export default function Ticket({ className, goHome }) {
2635 useLoginView(WALLET_TYPES.TICKET);
2736
2837 const { contracts } = useNetwork();
@@ -30,158 +39,171 @@ export default function Ticket({ className }) {
3039 const { setPointCursor } = usePointCursor();
3140 const impliedPoint = useImpliedPoint();
3241
33− // point
34− const [pointInput, { data: pointName }] = usePointInput({
35− name: 'point',
36− initialValue: impliedPoint || '',
37− autoFocus: true,
38− });
39−
40− // passphrase
41− const [passphraseInput, { data: passphrase }] = usePassphraseInput({
42− name: 'passphrase',
43− label: 'Wallet Passphrase',
44− });
45−
46− const [hasPassphraseInput, { data: hasPassphrase }] = useCheckboxInput({
47− name: 'has-passphrase',
48− label: 'Passphrase',
49− initialValue: false,
50− });
51−
52− // ticket
53− const [error, setError] = useState();
54− const [deriving, setDeriving] = useState(false);
55− const [ticketInput, { data: ticket, pass: validTicket }] = useTicketInput({
56− name: 'ticket',
57− label: 'Master Ticket',
58− error,
59− deriving,
60− });
61−
62− // shards
63− const [shardsInput, { data: isUsingShards }] = useCheckboxInput({
64− name: 'shards',
65− label: 'Shards',
66− initialValue: false,
67− });
68−
69− const [shard1Input, { data: shard1, pass: shard1Pass }] = useTicketInput({
70− name: 'shard1',
71− label: 'Shard 1',
72− });
73−
74− const [shard2Input, { data: shard2, pass: shard2Pass }] = useTicketInput({
75− name: 'shard2',
76− label: 'Shard 2',
77− });
78−
79− const [shard3Input, { data: shard3, pass: shard3Pass }] = useTicketInput({
80− name: 'shard3',
81− label: 'Shard 3',
82− });
83−
84− const shardsReady = shard1Pass && shard2Pass && shard3Pass;
85−
86− // TODO: maybe want to do this only on-go, because wallet derivation is slow...
87− const deriveWalletFromTicket = useCallback(async () => {
88− // clear states
89− setError();
90− setDeriving(true);
91− setUrbitWallet(Nothing());
92−
93− if (
94− !ticket ||
95− !pointName ||
96− !ob.isValidPatq(ticket) ||
97− !ob.isValidPatp(pointName)
98− ) {
99− setDeriving(false);
100− return;
101− }
102−
103− const _contracts = need.contracts(contracts);
104− const point = patp2dec(pointName);
105− const urbitWallet = await urbitWalletFromTicket(ticket, point, passphrase);
106− const [isOwner, isTransferProxy] = await Promise.all([
107− azimuth.azimuth.isOwner(
108− _contracts,
109− point,
110− urbitWallet.ownership.keys.address
111− ),
112− azimuth.azimuth.isTransferProxy(
113− _contracts,
114− point,
115− urbitWallet.ownership.keys.address
42+ const cachedUrbitWallet = useRef(Nothing());
43+
44+ const validateForm = useCallback(
45+ async (values, errors) => {
46+ if (errors.point) {
47+ return errors;
48+ }
49+
50+ if (values.useShards) {
51+ if (errors.shard1 || errors.shard2 || errors.shard3) {
52+ return errors;
53+ }
54+
55+ // shards
56+ try {
57+ const ticket = kg.combine([
58+ values.shard1,
59+ values.shard2,
60+ values.shard3,
61+ ]);
62+ const point = patp2dec(values.point);
63+ cachedUrbitWallet.current = await urbitWalletFromTicket(
64+ ticket,
65+ point,
66+ values.passphrase
67+ );
68+ } catch (error) {
69+ console.error(error);
70+ return { [FORM_ERROR]: 'Unable to derive wallet from shards.' };
71+ }
72+ } else {
73+ if (errors.ticket) {
74+ return errors;
75+ }
76+
77+ await timeout(100); // allow ui events to flush
78+ try {
79+ // ticket
80+ const _contracts = need.contracts(contracts);
81+ const point = patp2dec(values.point);
82+
83+ console.log('computing...');
84+ const urbitWallet = await urbitWalletFromTicket(
85+ values.ticket,
86+ point,
87+ values.passphrase
88+ );
89+
90+ cachedUrbitWallet.current = urbitWallet;
91+
92+ const [isOwner, isTransferProxy] = await Promise.all([
93+ azimuth.azimuth.isOwner(
94+ _contracts,
95+ point,
96+ urbitWallet.ownership.keys.address
97+ ),
98+ azimuth.azimuth.isTransferProxy(
99+ _contracts,
100+ point,
101+ urbitWallet.ownership.keys.address
102+ ),
103+ ]);
104+
105+ if (!isOwner && !isTransferProxy) {
106+ // notify the user, but allow login regardless
107+ // TODO: warnings
108+ // 'This ticket is not the owner of or transfer proxy for this point.'
109+ }
110+ } catch (error) {
111+ console.error(error);
112+ return { ticket: 'Unable to derive wallet from ticket.' };
113+ }
114+ }
115+ },
116+ [contracts]
117+ );
118+
119+ const validate = useMemo(
120+ () =>
121+ composeValidator(
122+ {
123+ usePassphrase: buildCheckboxValidator(),
124+ useShards: buildCheckboxValidator(),
125+ point: buildPointValidator(4),
126+ ticket: buildTicketValidator(),
127+ shard1: buildTicketValidator(),
128+ shard2: buildTicketValidator(),
129+ shard3: buildTicketValidator(),
130+ passphrase: buildPassphraseValidator(),
131+ },
132+ validateForm
116133 ),
117− ]);
118−
119− if (!isOwner && !isTransferProxy) {
120− // notify the user, but allow login regardless
121− setError(
122− 'This ticket is not the owner of or transfer proxy for this point.'
123− );
124− }
125−
126− setUrbitWallet(Just(urbitWallet));
127− setPointCursor(Just(point));
128− setDeriving(false);
129− }, [
130− pointName,
131− ticket,
132− passphrase,
133− contracts,
134− setUrbitWallet,
135− setPointCursor,
136− setDeriving,
137− ]);
138−
139− const deriveWalletFromShards = useCallback(async () => {
140− const s1 = shard1 || undefined;
141− const s2 = shard2 || undefined;
142− const s3 = shard3 || undefined;
143−
144− try {
145− const ticket = kg.combine([s1, s2, s3]);
146− const point = patp2dec(pointName);
147− const uhdw = await urbitWalletFromTicket(ticket, point, passphrase);
148− setUrbitWallet(Just(uhdw));
149− } catch (_) {
150− // do nothing
151− }
152− }, [passphrase, pointName, setUrbitWallet, shard1, shard2, shard3]);
153−
154− // derive wallet on change
155− useEffect(() => {
156− if (isUsingShards && shardsReady) {
157− deriveWalletFromShards();
158− } else if (validTicket) {
159− deriveWalletFromTicket();
160− }
161− }, [
162− isUsingShards,
163− validTicket,
164− shardsReady,
165− deriveWalletFromShards,
166− deriveWalletFromTicket,
167− ]);
134+ [validateForm]
135+ );
136+
137+ const onValues = useCallback(
138+ ({ valid, values }) => {
139+ if (valid) {
140+ setUrbitWallet(Just(cachedUrbitWallet.current));
141+ setPointCursor(Just(patp2dec(values.point)));
142+ } else {
143+ setUrbitWallet(Nothing());
144+ }
145+ },
146+ [setPointCursor, setUrbitWallet]
147+ );
168148
169149 return (
170150 <Grid className={cn('mt4', className)}>
171− <Grid.Item full as={Input} {...pointInput} />
172−
173− {!isUsingShards && <Grid.Item full as={Input} {...ticketInput} />}
174− {isUsingShards && (
175− <>
176− <Grid.Item full as={Input} {...shard1Input} />
177− <Grid.Item full as={Input} {...shard2Input} />
178− <Grid.Item full as={Input} {...shard3Input} />
179− </>
180− )}
181− {hasPassphrase && <Grid.Item full as={Input} {...passphraseInput} />}
182−
183− <Grid.Item full as={CheckboxInput} {...hasPassphraseInput} />
184− <Grid.Item full as={CheckboxInput} {...shardsInput} />
151+ <BridgeForm
152+ validate={validate}
153+ onValues={onValues}
154+ onSubmit={goHome}
155+ initialValues={{
156+ point: impliedPoint || '',
157+ usePasshrase: false,
158+ useShards: false,
159+ }}>
160+ {({ handleSubmit }) => (
161+ <>
162+ <Grid.Item full as={PointInput} name="point" />
163+
164+ <Condition when="useShards" is={false}>
165+ <Grid.Item
166+ full
167+ as={TicketInput}
168+ name="ticket"
169+ label="Master Ticket"
170+ />
171+ </Condition>
172+
173+ <Condition when="useShards" is={true}>
174+ <Grid.Item full as={TicketInput} name="shard1" label="Shard 1" />
175+ <Grid.Item full as={TicketInput} name="shard2" label="Shard 2" />
176+ <Grid.Item full as={TicketInput} name="shard3" label="Shard 3" />
177+ </Condition>
178+
179+ <Condition when="usePassphrase" is={true}>
180+ <Grid.Item
181+ full
182+ as={PassphraseInput}
183+ name="passphrase"
184+ label="Wallet Passphrase"
185+ />
186+ </Condition>
187+
188+ <Grid.Item
189+ full
190+ as={CheckboxInput}
191+ name="usePassphrase"
192+ label="Passphrase"
193+ />
194+ <Grid.Item
195+ full
196+ as={CheckboxInput}
197+ name="useShards"
198+ label="Shards"
199+ />
200+
201+ <Grid.Item full as={FormError} />
202+
203+ <Grid.Item full as={ContinueButton} handleSubmit={handleSubmit} />
204+ </>
205+ )}
206+ </BridgeForm>
185207 </Grid>
186208 );
187209}
src/views/Login/Trezor.js+95−75
@@ -1,4 +1,4 @@
1−import React, { useEffect } from 'react';
1+import React, { useCallback, useMemo } from 'react';
22import { Just, Nothing } from 'folktale/maybe';
33import * as bip32 from 'bip32';
44import { times } from 'lodash';
@@ -18,13 +18,17 @@ import { useWallet } from 'store/wallet';
1818import { TREZOR_PATH } from 'lib/trezor';
1919import { WALLET_TYPES } from 'lib/wallet';
2020import useLoginView from 'lib/useLoginView';
21−import {
22− useHdPathInput,
23− useCheckboxInput,
24− useSelectInput,
25−} from 'lib/useInputs';
2621
27−import { ForwardButton } from 'components/Buttons';
22+import {
23+ composeValidator,
24+ buildCheckboxValidator,
25+ buildSelectValidator,
26+ buildHdPathValidator,
27+} from 'form/Inputs';
28+import BridgeForm from 'form/BridgeForm';
29+import Condition from 'form/Condition';
30+import ContinueButton from './ContinueButton';
31+import FormError from 'form/FormError';
2832
2933const ACCOUNT_OPTIONS = times(20, i => ({
3034 text: `Account #${i + 1}`,
@@ -37,63 +41,51 @@ export default function Trezor({ className }) {
3741
3842 const { setWallet, setWalletHdPath } = useWallet();
3943
40− // custom toggle
41− const [customPathInput, { data: useCustomPath }] = useCheckboxInput({
42− name: 'customPath',
43− label: 'Custom HD Path',
44− autoComplete: 'off',
45− initialValue: false,
46− });
47−
48− // account input
49− const [accountInput, { data: accountIndex }] = useSelectInput({
50− name: 'account',
51− label: 'Account',
52− placeholder: 'Choose account...',
53− options: ACCOUNT_OPTIONS,
54− });
55−
56− // hd path input
57− const [
58− hdPathInput,
59− { data: hdPath },
60− { setValue: setHdPath },
61− ] = useHdPathInput({
62− name: 'hdpath',
63− label: 'HD Path',
64− initialValue: TREZOR_PATH.replace(/x/g, 0),
65− });
66−
67− const pollDevice = async () => {
68− TrezorConnect.manifest({
69− email: 'bridge-trezor@urbit.org',
70− appUrl: 'https://github.com/urbit/bridge',
71− });
72−
73− const info = await TrezorConnect.getPublicKey({
74− path: hdPath,
75− });
76−
77− if (info.success === true) {
78− const payload = info.payload;
79− const publicKey = Buffer.from(payload.publicKey, 'hex');
80− const chainCode = Buffer.from(payload.chainCode, 'hex');
81− const pub = secp256k1.publicKeyConvert(publicKey, true);
82− const hd = bip32.fromPublicKey(pub, chainCode);
83− setWallet(Just(hd));
84− setWalletHdPath(hdPath);
85− } else {
86− setWallet(Nothing());
44+ const validate = useMemo(
45+ () =>
46+ composeValidator({
47+ useCustomPath: buildCheckboxValidator(),
48+ account: buildSelectValidator(ACCOUNT_OPTIONS),
49+ hdpath: buildHdPathValidator(),
50+ }),
51+ []
52+ );
53+
54+ const onSubmit = useCallback(
55+ async values => {
56+ TrezorConnect.manifest({
57+ email: 'bridge-trezor@urbit.org',
58+ appUrl: 'https://github.com/urbit/bridge',
59+ });
60+
61+ const info = await TrezorConnect.getPublicKey({
62+ path: values.hdpath,
63+ });
64+
65+ if (info.success === true) {
66+ const payload = info.payload;
67+ const publicKey = Buffer.from(payload.publicKey, 'hex');
68+ const chainCode = Buffer.from(payload.chainCode, 'hex');
69+ const pub = secp256k1.publicKeyConvert(publicKey, true);
70+ const hd = bip32.fromPublicKey(pub, chainCode);
71+ setWallet(Just(hd));
72+ setWalletHdPath(values.hdPath);
73+ } else {
74+ setWallet(Nothing());
75+ }
76+ },
77+ [setWallet, setWalletHdPath]
78+ );
79+
80+ const onValues = useCallback(({ valid, values, form }) => {
81+ if (!valid) {
82+ return;
8783 }
88− };
8984
90− useEffect(() => {
91− if (useCustomPath) {
92− // updated by useForm
93− } else {
94− setHdPath(TREZOR_PATH.replace(/x/g, accountIndex));
85+ if (!values.useCustomPath) {
86+ form.change('hdpath', TREZOR_PATH.replace(/x/g, values.account));
9587 }
96− }, [useCustomPath, setHdPath, accountIndex]);
88+ }, []);
9789
9890 return (
9991 <Grid className={className}>
@@ -106,20 +98,48 @@ export default function Trezor({ className }) {
10698 derivation path, you may enter it below.
10799 </Grid.Item>
108100
109− {useCustomPath && <Grid.Item full as={Input} {...hdPathInput} />}
110−
111− {!useCustomPath && <Grid.Item full as={SelectInput} {...accountInput} />}
112−
113− <Grid.Item full as={CheckboxInput} className="mv3" {...customPathInput} />
114−
115− <Grid.Item
116− full
117− as={ForwardButton}
118− solid
119− className="mt2"
120− onClick={pollDevice}>
121− Authenticate
122− </Grid.Item>
101+ <BridgeForm
102+ validate={validate}
103+ onValues={onValues}
104+ onSubmit={onSubmit}
105+ initialValues={{
106+ useCustomPath: false,
107+ hdpath: TREZOR_PATH.replace(/x/g, ACCOUNT_OPTIONS[0].value),
108+ account: ACCOUNT_OPTIONS[0].value,
109+ }}>
110+ {({ handleSubmit }) => (
111+ <>
112+ <Condition when="useCustomPath" is={true}>
113+ <Grid.Item full as={Input} name="hdpath" label="HD Path" />
114+ </Condition>
115+
116+ <Condition when="useCustomPath" is={false}>
117+ <Grid.Item
118+ full
119+ as={SelectInput}
120+ name="account"
121+ label="Account"
122+ placeholder="Choose account..."
123+ options={ACCOUNT_OPTIONS}
124+ />
125+ </Condition>
126+
127+ <Grid.Item
128+ full
129+ as={CheckboxInput}
130+ className="mv3"
131+ name="useCustomPath"
132+ label="Custom HD Path"
133+ />
134+
135+ <Grid.Item full as={FormError} />
136+
137+ <Grid.Item full as={ContinueButton} handleSubmit={handleSubmit}>
138+ Authenticate
139+ </Grid.Item>
140+ </>
141+ )}
142+ </BridgeForm>
123143 </Grid>
124144 );
125145}