on master help sign in
feat: initial implementation of final-form on login
f4fd597Matt Condon (shrugs)
src/Bridge.js+3−3
@@ -10,7 +10,7 @@ import Provider from 'store/Provider';
10import { ROUTE_NAMES } from 'lib/routeNames';
10import { ROUTE_NAMES } from 'lib/routeNames';
11import { ROUTES } from 'lib/router';
11import { ROUTES } from 'lib/router';
12import { NETWORK_TYPES } from 'lib/network';
12import { NETWORK_TYPES } from 'lib/network';
13−import { walletFromMnemonic, WALLET_TYPES } from 'lib/wallet';
13+import { walletFromMnemonic } from 'lib/wallet';
14import { isDevelopment } from 'lib/flags';
14import { isDevelopment } from 'lib/flags';
15import useImpliedTicket from 'lib/useImpliedTicket';
15import useImpliedTicket from 'lib/useImpliedTicket';
16import useHasDisclaimed from 'lib/useHasDisclaimed';
16import useHasDisclaimed from 'lib/useHasDisclaimed';
@@ -43,8 +43,8 @@ function useInitialRoutes() {
43 if (IS_STUBBED) {
43 if (IS_STUBBED) {
44 return [
44 return [
45 { key: ROUTE_NAMES.LOGIN },
45 { key: ROUTE_NAMES.LOGIN },
46− // { key: ROUTE_NAMES.POINTS },
46+ { key: ROUTE_NAMES.POINTS },
47− // { key: ROUTE_NAMES.POINT },
47+ { key: ROUTE_NAMES.POINT },
48 ];
48 ];
49 }
49 }
50
50
src/components/Accordion.js+4−2
@@ -2,11 +2,13 @@ import React from 'react';
2import { Grid, AccessoryIcon } from 'indigo-react';
2import { Grid, AccessoryIcon } from 'indigo-react';
3
3
4export default function Accordion({
4export default function Accordion({
5+ className,
5 views,
6 views,
6 options,
7 options,
7 currentTab,
8 currentTab,
8 onTabChange,
9 onTabChange,
9− className,
10+
11+ // Tab props
10 ...rest
12 ...rest
11}) {
13}) {
12 const Tab = views[currentTab];
14 const Tab = views[currentTab];
@@ -37,7 +39,7 @@ export default function Accordion({
37 </AccessoryIcon>
39 </AccessoryIcon>
38 </div>
40 </div>
39 </Grid.Item>
41 </Grid.Item>
40− {isActive && <Grid.Item full as={Tab} />}
42+ {isActive && <Grid.Item full as={Tab} {...rest} />}
41 <Grid.Divider />
43 <Grid.Divider />
42 </React.Fragment>
44 </React.Fragment>
43 );
45 );
src/form/Autosaver.js+4−3
@@ -1,7 +1,8 @@
1import { useEffect } from 'react';
1import { useEffect } from 'react';
2−import { useFormState } from 'react-final-form';
2+import { useFormState, useForm } from 'react-final-form';
3
3
4export default function Autosaver({ onValues }) {
4export default function Autosaver({ onValues }) {
5+ const form = useForm();
5 const { valid, validating, values } = useFormState({
6 const { valid, validating, values } = useFormState({
6 subscription: {
7 subscription: {
7 valid: true,
8 valid: true,
@@ -12,9 +13,9 @@ export default function Autosaver({ onValues }) {
12
13
13 useEffect(() => {
14 useEffect(() => {
14 if (!validating) {
15 if (!validating) {
15− onValues && onValues({ valid: valid && !validating, values });
16+ onValues && onValues({ valid: valid && !validating, values, form });
16 }
17 }
17− }, [onValues, valid, validating, values]);
18+ }, [form, onValues, valid, validating, values]);
18
19
19 return null;
20 return null;
20}
21}
src/form/BridgeForm.js+2−2
@@ -2,17 +2,17 @@ import React from 'react';
2import { Form } from 'react-final-form';
2import { Form } from 'react-final-form';
3import setFieldData from 'final-form-set-field-data';
3import setFieldData from 'final-form-set-field-data';
4
4
5−import FormError from './FormError';
6import Autosaver from './Autosaver';
5import Autosaver from './Autosaver';
6+import ValidationPauser from './ValidationPauser';
7
7
8export default function BridgeForm({ children, onValues, ...rest }) {
8export default function BridgeForm({ children, onValues, ...rest }) {
9 return (
9 return (
10 <Form mutators={{ setFieldData }} {...rest}>
10 <Form mutators={{ setFieldData }} {...rest}>
11 {formProps => (
11 {formProps => (
12 <>
12 <>
13+ <ValidationPauser />
13 {children(formProps)}
14 {children(formProps)}
14 {onValues && <Autosaver onValues={onValues} />}
15 {onValues && <Autosaver onValues={onValues} />}
15− <FormError />
16 </>
16 </>
17 )}
17 )}
18 </Form>
18 </Form>
src/form/FormError.js+10−3
@@ -1,9 +1,16 @@
1import React from 'react';
1import React from 'react';
2import { useFormState } from 'react-final-form';
2import { useFormState } from 'react-final-form';
3import { ErrorText } from 'indigo-react';
3import { ErrorText } from 'indigo-react';
4+import { FORM_ERROR } from 'final-form';
4
5
5−export default function FormError() {
6+export default function FormError(props) {
6− const { submitError } = useFormState({ subscription: { submitError: true } });
7+ const { submitError, errors } = useFormState({
8+ subscription: { submitError: true, errors: true },
9+ });
7
10
11+ const formError = errors[FORM_ERROR];
12+
13+ return submitError || formError ? (
8− return submitError ? <ErrorText>{submitError}</ErrorText> : null;
14+ <ErrorText {...props}>{submitError || formError}</ErrorText>
15+ ) : null;
9}
16}
src/form/Inputs.js+123−59
@@ -1,22 +1,23 @@
1−import React, { useMemo } from 'react';
1+import React from 'react';
2import { Input, AccessoryIcon } from 'indigo-react';
2import { Input, AccessoryIcon } from 'indigo-react';
3import { useField } from 'react-final-form';
3import { useField } from 'react-final-form';
4+import { some } from 'lodash';
4
5
5import {
6import {
6 validateNotEmpty,
7 validateNotEmpty,
7 validateTicket,
8 validateTicket,
8 kDefaultValidator,
9 kDefaultValidator,
9 validateMnemonic,
10 validateMnemonic,
11+ validatePoint,
12+ validateMaximumPatpByteLength,
13+ validateOneOf,
14+ validateHexString,
15+ validateHexLength,
10} from 'lib/validators';
16} from 'lib/validators';
11import { compose } from 'lib/lib';
17import { compose } from 'lib/lib';
12import { prependSig } from 'lib/transformers';
18import { prependSig } from 'lib/transformers';
13import { DEFAULT_HD_PATH } from 'lib/wallet';
19import { DEFAULT_HD_PATH } from 'lib/wallet';
14−
20+import InputSigil from 'components/InputSigil';
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
21
21const PLACEHOLDER_POINT = '~sampel-ponnym';
22const PLACEHOLDER_POINT = '~sampel-ponnym';
22const PLACEHOLDER_HD_PATH = DEFAULT_HD_PATH;
23const PLACEHOLDER_HD_PATH = DEFAULT_HD_PATH;
@@ -29,9 +30,8 @@ const PLACEHOLDER_PRIVATE_KEY =
29
30
30const buildValidator = (
31const buildValidator = (
31 validators = [],
32 validators = [],
32− fn = x => undefined
33+ fn = () => undefined
33) => async value => {
34) => async value => {
34− // console.log('validating:', value);
35 return (
35 return (
36 compose(
36 compose(
37 ...validators,
37 ...validators,
@@ -40,24 +40,78 @@ const buildValidator = (
40 );
40 );
41};
41};
42
42
43+export const hasErrors = obj => some(obj, v => v !== undefined);
44+
45+export const buildTicketValidator = (validators = []) =>
43−const kTicketValidators = [validateTicket, validateNotEmpty];
46+ buildValidator([...validators, validateTicket, validateNotEmpty]);
44−export function TicketInput({
47+export const buildMnemonicValidator = () =>
45− name,
48+ buildValidator([validateMnemonic, validateNotEmpty]);
46− validators = kEmptyValidators,
49+export const buildCheckboxValidator = () =>
47− transformers = kEmptyTransformers,
50+ buildValidator([validateOneOf([true, false])]);
51+export const buildPassphraseValidator = () => buildValidator([]);
48− validate,
52+// TODO: validate hdpath format
49− ...rest
53+export const buildHdPathValidator = () => buildValidator([validateNotEmpty]);
50−}) {
54+export const buildPointValidator = (size = 4) =>
51− const { valid, error, validating } = useField(name, {
55+ buildValidator([
52− subscription: { error: true, validating: true },
56+ validatePoint,
53− });
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);
54
77
55− const _validate = useMemo(
78+ const fieldLevelValidators = names.map(name => value =>
56− () => buildValidator([...validators, ...kTicketValidators], validate),
79+ fieldValidators[name](value)
57− [validate, validators]
58 );
80 );
59
81
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+ });
61
115
62 return (
116 return (
63 <Input
117 <Input
@@ -65,7 +119,7 @@ export function TicketInput({
65 name={name}
119 name={name}
66 placeholder={PLACEHOLDER_TICKET}
120 placeholder={PLACEHOLDER_TICKET}
67 accessory={
121 accessory={
68− error ? (
122+ touched && !active && error ? (
69 <AccessoryIcon.Failure />
123 <AccessoryIcon.Failure />
70 ) : validating ? (
124 ) : validating ? (
71 <AccessoryIcon.Pending />
125 <AccessoryIcon.Pending />
@@ -73,69 +127,79 @@ export function TicketInput({
73 <AccessoryIcon.Success />
127 <AccessoryIcon.Success />
74 ) : null
128 ) : null
75 }
129 }
76− config={{ validate: _validate, format: _format }}
130+ config={{ format: prependSig }}
77 mono
131 mono
78 {...rest}
132 {...rest}
79 />
133 />
80 );
134 );
81}
135}
82
136
83−const kMnemonicValidators = [validateMnemonic, validateNotEmpty];
84−export function MnemonicInput({
137+export function MnemonicInput({ ...rest }) {
85− name,
138+ return (
86− validators = kEmptyValidators,
139+ <Input type="textarea" placeholder={PLACEHOLDER_MNEMONIC} mono {...rest} />
87− transformers = kEmptyTransformers,
88− validate,
89− ...rest
90−}) {
91− const _validate = useMemo(
92− () => buildValidator([...validators, ...kMnemonicValidators], validate),
93− [validate, validators]
94 );
140 );
141+}
95
142
143+export function HdPathInput({ ...rest }) {
96 return (
144 return (
97 <Input
145 <Input
98− type="textarea"
146+ type="text"
99− name={name}
147+ placeholder={PLACEHOLDER_HD_PATH}
100− placeholder={PLACEHOLDER_MNEMONIC}
101− config={{ validate: _validate }}
102 autoComplete="off"
148 autoComplete="off"
103− mono
104 {...rest}
149 {...rest}
105 />
150 />
106 );
151 );
107}
152}
108
153
109−const kHdPathValidators = [validateNotEmpty];
110−export function HdPathInput({
154+export function PassphraseInput({ ...rest }) {
111− name,
155+ return (
112− validators = kEmptyValidators,
156+ <Input
113− transformers = kEmptyTransformers,
157+ type="password"
114− validate,
158+ placeholder="Passphrase"
115− ...rest
159+ autoComplete="off"
116−}) {
160+ {...rest}
117− const _validate = useMemo(
161+ />
118− () => buildValidator([...validators, ...kHdPathValidators], validate),
119− [validate, validators]
120 );
162 );
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+ });
121
172
122 return (
173 return (
123 <Input
174 <Input
124 type="text"
175 type="text"
176+ label="Point"
125 name={name}
177 name={name}
126− placeholder={PLACEHOLDER_HD_PATH}
178+ placeholder={PLACEHOLDER_POINT}
127− config={{ validate: _validate }}
179+ accessory={
128− autoComplete="off"
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
129 {...rest}
193 {...rest}
130 />
194 />
131 );
195 );
132}
196}
133
197
134−export function PassphraseInput({ ...rest }) {
198+export function PrivateKeyInput({ ...rest }) {
135 return (
199 return (
136 <Input
200 <Input
137− type="password"
201+ type="text"
138− placeholder="Passphrase"
202+ placeholder={PLACEHOLDER_PRIVATE_KEY}
139 autoComplete="off"
203 autoComplete="off"
140 {...rest}
204 {...rest}
141 />
205 />
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';
4
4
5const PENDING_ACCESSORY = '⋯';
5const PENDING_ACCESSORY = '⋯';
6const SUCCESS_ACCESSORY = '✓';
6const SUCCESS_ACCESSORY = '✓';
7−const FAILURE_ACCESSORY = '✗';
7+const FAILURE_ACCESSORY = '!';
8
8
9function AccessoryIcon({ ...props }) {
9function AccessoryIcon({ ...props }) {
10 return (
10 return (
src/indigo-react/components/CheckboxInput.js+2−11
@@ -15,7 +15,7 @@ export default function CheckboxInput({
15 const {
15 const {
16 input,
16 input,
17 meta: { submitting },
17 meta: { submitting },
18− } = useField(name);
18+ } = useField(name, { type: 'checkbox' });
19
19
20 disabled = disabled || submitting;
20 disabled = disabled || submitting;
21
21
@@ -34,16 +34,7 @@ export default function CheckboxInput({
34 }),
34 }),
35 }}>
35 }}>
36 {/* we totally hide the checkbox itself */}
36 {/* we totally hide the checkbox itself */}
37− <Flex.Item
38− as="input"
39− type="checkbox"
40− className="super-hidden"
37+ <Flex.Item as="input" className="super-hidden" id={name} {...input} />
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− />
47 {/* and then display a prettier one in its stead */}
38 {/* and then display a prettier one in its stead */}
48 <Flex.Item
39 <Flex.Item
49 flex
40 flex
src/indigo-react/components/Input.js+9−24
@@ -1,4 +1,4 @@
1−import React, { useCallback } from 'react';
1+import React from 'react';
2import cn from 'classnames';
2import cn from 'classnames';
3
3
4import Flex from './Flex';
4import Flex from './Flex';
@@ -26,31 +26,17 @@ export default function Input({
26}) {
26}) {
27 const {
27 const {
28 input,
28 input,
29− meta: {
30− active,
31− data,
32− dirty,
33− error,
34− modified,
35− pristine,
36− submitError,
37− submitFailed,
38− submitSucceeded,
39− submitting,
29+ meta: { active, error, submitting, touched, valid },
40− touched,
41− valid,
42− validating,
43− visited,
44− },
45 } = useField(name, config);
30 } = useField(name, config);
46
31
47 disabled = disabled || submitting;
32 disabled = disabled || submitting;
48
33
49 // notify parent of enter keypress iff not disabled and passing
34 // notify parent of enter keypress iff not disabled and passing
35+ // TODO: integrate this into react-final-form submission
50− const onKeyPress = useCallback(
36+ // const onKeyPress = useCallback(
51− e => !disabled && valid && e.key === 'Enter' && onEnter && onEnter(),
37+ // e => !disabled && valid && e.key === 'Enter' && onEnter && onEnter(),
52− [disabled, valid] // eslint-disable-line react-hooks/exhaustive-deps
38+ // [disabled, valid] // eslint-disable-line react-hooks/exhaustive-deps
53− );
39+ // );
54
40
55 return (
41 return (
56 <Flex
42 <Flex
@@ -75,7 +61,6 @@ export default function Input({
75 <Flex.Item
61 <Flex.Item
76 flex
62 flex
77 as={type === 'textarea' ? 'textarea' : 'input'}
63 as={type === 'textarea' ? 'textarea' : 'input'}
78− type={type === 'textarea' ? undefined : type}
79 {...rest}
64 {...rest}
80 // NOTE: 24px = 12px * 2 (from p3 styling)
65 // NOTE: 24px = 12px * 2 (from p3 styling)
81 style={type === 'textarea' ? { minHeight: 'calc(1rem + 24px)' } : {}}
66 style={type === 'textarea' ? { minHeight: 'calc(1rem + 24px)' } : {}}
@@ -99,8 +84,8 @@ export default function Input({
99 )}
84 )}
100 id={name}
85 id={name}
101 name={name}
86 name={name}
102− onKeyPress={onKeyPress}
103 {...input}
87 {...input}
88+ type={type === 'textarea' ? undefined : type}
104 />
89 />
105 {accessory && (
90 {accessory && (
106 <div
91 <div
@@ -116,7 +101,7 @@ export default function Input({
116 </div>
101 </div>
117 )}
102 )}
118 </Flex.Item>
103 </Flex.Item>
119− {touched && error && (
104+ {touched && !active && error && (
120 <Flex.Item as={ErrorText} className="mv1">
105 <Flex.Item as={ErrorText} className="mv1">
121 {error}
106 {error}
122 </Flex.Item>
107 </Flex.Item>
src/indigo-react/components/SelectInput.js+27−47
@@ -5,65 +5,47 @@ import Flex from './Flex';
5import { ErrorText } from './Typography';
5import { ErrorText } from './Typography';
6import useOnClickOutside from 'indigo-react/lib/useOnClickOutside';
6import useOnClickOutside from 'indigo-react/lib/useOnClickOutside';
7import AccessoryIcon from './AccessoryIcon';
7import AccessoryIcon from './AccessoryIcon';
8+import { useField } from 'react-final-form';
8
9
9// NOTE: if we really care about accessibility, we should pull in a dependency
10// NOTE: if we really care about accessibility, we should pull in a dependency
10export default function SelectInput({
11export default function SelectInput({
11− // visuals
12 name,
12 name,
13 label,
13 label,
14+ placeholder,
14 className,
15 className,
15− accessory,
16 mono = false,
16 mono = false,
17− placeholder,
17+ options = [],
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,
32 disabled,
18 disabled,
33− options,
19+}) {
34− touched,
20+ const {
21+ input,
22+ meta: { active, error, submitting, touched, valid },
23+ } = useField(name, {
24+ type: 'select',
25+ });
35
26
36− // ignored
27+ disabled = disabled || submitting;
37− initialValue,
38− validators,
39− transformers,
40
28
41− // extra
42− ...rest
43−}) {
44 const [isOpen, setIsOpen] = useState(false);
29 const [isOpen, setIsOpen] = useState(false);
45 const ref = useRef();
30 const ref = useRef();
46
31
47 // close select on outside clicks
32 // close select on outside clicks
48 useOnClickOutside(ref, useCallback(() => setIsOpen(false), [setIsOpen]));
33 useOnClickOutside(ref, useCallback(() => setIsOpen(false), [setIsOpen]));
49
34
50− const toggleOpen = useCallback(() => setIsOpen(isOpen => !isOpen), [
35+ const toggleOpen = useCallback(() => {
51− setIsOpen,
36+ input.onFocus();
37+ setIsOpen(isOpen => !isOpen);
52− ]);
38+ }, [input]);
53
39
54 const onChange = value => {
40 const onChange = value => {
55− // TODO: provide setValue here?
56 // construct a pseudo event that sets the value correctly
41 // construct a pseudo event that sets the value correctly
57− bind.onChange({ target: { value } });
42+ input.onChange({ target: { value } });
43+ input.onBlur();
58 setIsOpen(false);
44 setIsOpen(false);
59 };
45 };
60
46
61− // redefine accessory because we still want to ignore it from the ..rest above
47+ console.log(input);
62− accessory = (
63− <AccessoryIcon className="gray4">{isOpen ? '▲' : '▼'}</AccessoryIcon>
64− );
65−
66− const text = options.find(o => o.value === data).text;
48+ const text = options.find(o => o.value === input.value).text;
67
49
68 return (
50 return (
69 <Flex
51 <Flex
@@ -81,7 +63,7 @@ export default function SelectInput({
81 </Flex.Item>
63 </Flex.Item>
82 <Flex.Item as={Flex} row className="rel clickable" onClick={toggleOpen}>
64 <Flex.Item as={Flex} row className="rel clickable" onClick={toggleOpen}>
83 <Flex.Item
65 <Flex.Item
84− // as="select"
66+ flex
85 className={cn(
67 className={cn(
86 'b b1 p3 outline-none',
68 'b b1 p3 outline-none',
87 { mono },
69 { mono },
@@ -94,16 +76,14 @@ export default function SelectInput({
94 black: !isOpen,
76 black: !isOpen,
95 },
77 },
96 {
78 {
97− 'b-green3': visiblyPassed,
79+ 'b-green3': valid,
98− 'b-black': focused && !visiblyPassed,
80+ 'b-black': !valid && active,
99− 'b-yellow3': !focused && hintError,
81+ 'b-yellow3': !valid && !active && touched && error,
100− 'b-gray3': !focused && !hintError && !visiblyPassed,
82+ 'b-gray2': !valid && !active && !touched && !error,
101 }
83 }
102 )}
84 )}
103 id={name}
85 id={name}
104− name={name}
86+ name={name}>
105− {...bind}
106− flex>
107 {isOpen ? placeholder : text}
87 {isOpen ? placeholder : text}
108 </Flex.Item>
88 </Flex.Item>
109 <div
89 <div
@@ -115,7 +95,7 @@ export default function SelectInput({
115 width: '44px',
95 width: '44px',
116 overflow: 'hidden',
96 overflow: 'hidden',
117 }}>
97 }}>
118− {accessory}
98+ <AccessoryIcon className="gray4">{isOpen ? '▲' : '▼'}</AccessoryIcon>
119 </div>
99 </div>
120 {isOpen && (
100 {isOpen && (
121 <Flex
101 <Flex
@@ -142,7 +122,7 @@ export default function SelectInput({
142 </Flex>
122 </Flex>
143 )}
123 )}
144 </Flex.Item>
124 </Flex.Item>
145− {error && (
125+ {touched && !active && error && (
146 <Flex.Item as={ErrorText} className="mv1">
126 <Flex.Item as={ErrorText} className="mv1">
147 {error}
127 {error}
148 </Flex.Item>
128 </Flex.Item>
src/indigo-react/components/ToggleInput.js+12−22
@@ -3,6 +3,7 @@ import cn from 'classnames';
3
3
4import Flex from './Flex';
4import Flex from './Flex';
5import LinkButton from './LinkButton';
5import LinkButton from './LinkButton';
6+import { useField } from 'react-final-form';
6
7
7export default function ToggleInput({
8export default function ToggleInput({
8 // visuals
9 // visuals
@@ -11,32 +12,21 @@ export default function ToggleInput({
11 inverseLabel,
12 inverseLabel,
12 className,
13 className,
13
14
14− // callbacks
15+ //
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,
27 disabled,
16 disabled,
28− touched,
29−
30− // ignored
31− initialValue,
32− validators,
33− transformers,
34
17
35 ...rest
18 ...rest
36}) {
19}) {
20+ const {
21+ input,
22+ meta: { submitting },
23+ } = useField(name, { type: 'checkbox' });
24+
25+ disabled = disabled || submitting;
26+
37 return (
27 return (
38 <Flex
28 <Flex
39− className={cn(className)}
29+ className={className}
40 row
30 row
41 align="center"
31 align="center"
42 style={{
32 style={{
@@ -52,7 +42,7 @@ export default function ToggleInput({
52 className={cn('super-hidden')}
42 className={cn('super-hidden')}
53 id={name}
43 id={name}
54 name={name}
44 name={name}
55− {...bind}
45+ {...input}
56 />
46 />
57 {/* and then display a prettier one in its stead */}
47 {/* and then display a prettier one in its stead */}
58 <Flex.Item
48 <Flex.Item
@@ -63,7 +53,7 @@ export default function ToggleInput({
63 })}
53 })}
64 htmlFor={name}>
54 htmlFor={name}>
65 <LinkButton disabled={disabled} className="f5">
55 <LinkButton disabled={disabled} className="f5">
66− {bind.checked ? inverseLabel : label}
56+ {input.checked ? inverseLabel : label}
67 </LinkButton>
57 </LinkButton>
68 </Flex.Item>
58 </Flex.Item>
69 </Flex>
59 </Flex>
src/lib/useInputs.js+2−4
@@ -214,10 +214,8 @@ export function useSelectInput({ initialValue, options, ...rest }) {
214 type: 'select',
214 type: 'select',
215 validators: useMemo(
215 validators: useMemo(
216 () => [
216 () => [
217− validateOneOf(
218− options.map(option => option.value),
217+ validateOneOf(options.map(option => option.value)),
219− validateNotEmpty
218+ validateNotEmpty,
220− ),
221 ],
219 ],
222 [options]
220 [options]
223 ),
221 ),
src/lib/validators.js+13
@@ -225,6 +225,19 @@ export const validateLength = l => m =>
225 error: `Must be exactly ${l} characters.`,
225 error: `Must be exactly ${l} characters.`,
226 });
226 });
227
227
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+
228export const validateMaximumLength = l => m =>
241export const validateMaximumLength = l => m =>
229 simpleValidatorWrapper({
242 simpleValidatorWrapper({
230 prevMessage: m,
243 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';
2import { Just, Nothing } from 'folktale/maybe';
2import { Just, Nothing } from 'folktale/maybe';
3import * as azimuth from 'azimuth-js';
3import * as azimuth from 'azimuth-js';
4import { Grid, H4 } from 'indigo-react';
4import { Grid, H4 } from 'indigo-react';
@@ -24,7 +24,14 @@ import useHasDisclaimed from 'lib/useHasDisclaimed';
24
24
25import BridgeForm from 'form/BridgeForm';
25import BridgeForm from 'form/BridgeForm';
26import SubmitButton from 'form/SubmitButton';
26import SubmitButton from 'form/SubmitButton';
27+import {
28+ TicketInput,
29+ hasErrors,
30+ composeValidator,
31+ buildTicketValidator,
27−import { TicketInput } from 'form/Inputs';
32+} from 'form/Inputs';
33+import FormError from 'form/FormError';
34+import { FORM_ERROR } from 'final-form';
28
35
29export default function ActivateCode() {
36export default function ActivateCode() {
30 const history = useHistory();
37 const history = useHistory();
@@ -57,8 +64,14 @@ export default function ActivateCode() {
57 // validate should be a pure function but we don't want to have to recompute
64 // validate should be a pure function but we don't want to have to recompute
58 // all of this information on submit, so cache the invite wallet and avoid
65 // all of this information on submit, so cache the invite wallet and avoid
59 // re-renders that may trigger re-validations (causing infinite loop)
66 // re-renders that may trigger re-validations (causing infinite loop)
60− const validate = useCallback(
67+ const validateForm = useCallback(
61− async ticket => {
68+ async ({ ticket }, errors) => {
69+ if (hasErrors(errors)) {
70+ return errors;
71+ }
72+
73+ console.log('FUCK — deriving seed');
74+
62 await timeout(100); // allow the ui changes to flush before we lag it out
75 await timeout(100); // allow the ui changes to flush before we lag it out
63
76
64 const _contracts = need.contracts(contracts);
77 const _contracts = need.contracts(contracts);
@@ -93,26 +106,32 @@ export default function ActivateCode() {
93 setDerivedPoint(Just(point));
106 setDerivedPoint(Just(point));
94 cachedPoint.current = point;
107 cachedPoint.current = point;
95 } else {
108 } else {
96− return (
109+ return {
110+ [FORM_ERROR]:
97− 'Invite code has no claimable point.\n' +
111+ 'Invite code has no claimable point.\n' +
98− 'Check your invite code and try again?'
112+ 'Check your invite code and try again?',
99− );
113+ };
100 }
114 }
101 },
115 },
102 [contracts, setDerivedPoint]
116 [contracts, setDerivedPoint]
103 );
117 );
104
118
119+ const validate = useMemo(
120+ () => composeValidator({ ticket: buildTicketValidator() }, validateForm),
121+ [validateForm]
122+ );
123+
105 // set our state on submission
124 // set our state on submission
106 const onSubmit = useCallback(
125 const onSubmit = useCallback(
107 async values => {
126 async values => {
108 setInviteWallet(cachedInviteWallet.current);
127 setInviteWallet(cachedInviteWallet.current);
109 setDerivedWallet(Just(await generateWallet(cachedPoint)));
128 setDerivedWallet(Just(await generateWallet(cachedPoint)));
110−
111− goToPassport();
112 },
129 },
113− [goToPassport, setDerivedWallet, setInviteWallet]
130+ [setDerivedWallet, setInviteWallet]
114 );
131 );
115
132
133+ const afterSubmit = useCallback(async () => goToPassport(), [goToPassport]);
134+
116 return (
135 return (
117 <View inset>
136 <View inset>
118 <Grid>
137 <Grid>
@@ -121,7 +140,9 @@ export default function ActivateCode() {
121 Activate
140 Activate
122 </Grid.Item>
141 </Grid.Item>
123 <BridgeForm
142 <BridgeForm
143+ validate={validate}
124 onSubmit={onSubmit}
144 onSubmit={onSubmit}
145+ afterSubmit={afterSubmit}
125 initialValues={{ ticket: impliedTicket || '' }}>
146 initialValues={{ ticket: impliedTicket || '' }}>
126 {({ validating, submitting, handleSubmit }) => (
147 {({ validating, submitting, handleSubmit }) => (
127 <>
148 <>
@@ -130,10 +151,10 @@ export default function ActivateCode() {
130 as={TicketInput}
151 as={TicketInput}
131 name="ticket"
152 name="ticket"
132 label="Activation Code"
153 label="Activation Code"
133− validate={validate}
134− autoFocus
135 />
154 />
136
155
156+ <Grid.Item full as={FormError} />
157+
137 <Grid.Item
158 <Grid.Item
138 full
159 full
139 as={SubmitButton}
160 as={SubmitButton}
src/views/Activate/PassportVerify.js+16−5
@@ -7,11 +7,16 @@ import { validateExactly } from 'lib/validators';
7import { isDevelopment } from 'lib/flags';
7import { isDevelopment } from 'lib/flags';
8
8
9import SubmitButton from 'form/SubmitButton';
9import SubmitButton from 'form/SubmitButton';
10+import {
11+ TicketInput,
12+ composeValidator,
13+ buildTicketValidator,
10−import { TicketInput } from 'form/Inputs';
14+} from 'form/Inputs';
11import BridgeForm from 'form/BridgeForm';
15import BridgeForm from 'form/BridgeForm';
12
16
13import { useActivateFlow } from './ActivateFlow';
17import { useActivateFlow } from './ActivateFlow';
14import PassportView from './PassportView';
18import PassportView from './PassportView';
19+import FormError from 'form/FormError';
15
20
16const STUB_VERIFY_TICKET = isDevelopment;
21const STUB_VERIFY_TICKET = isDevelopment;
17
22
@@ -21,8 +26,13 @@ export default function PassportVerify({ className }) {
21 const goToTransfer = useCallback(() => push(names.TRANSFER), [push, names]);
26 const goToTransfer = useCallback(() => push(names.TRANSFER), [push, names]);
22
27
23 const { ticket } = need.wallet(derivedWallet);
28 const { ticket } = need.wallet(derivedWallet);
24− const validators = useMemo(
29+ const validate = useMemo(
30+ () =>
31+ composeValidator({
32+ ticket: buildTicketValidator([
25− () => [validateExactly(ticket, 'Does not match expected master ticket.')],
33+ validateExactly(ticket, 'Does not match expected master ticket.'),
34+ ]),
35+ }),
26 [ticket]
36 [ticket]
27 );
37 );
28
38
@@ -37,6 +47,7 @@ export default function PassportVerify({ className }) {
37 Open it and enter the 4 word phrase below (with hyphens).
47 Open it and enter the 4 word phrase below (with hyphens).
38 </Grid.Item>
48 </Grid.Item>
39 <BridgeForm
49 <BridgeForm
50+ validate={validate}
40 onSubmit={onSubmit}
51 onSubmit={onSubmit}
41 initialValues={{
52 initialValues={{
42 ticket: STUB_VERIFY_TICKET ? ticket : undefined,
53 ticket: STUB_VERIFY_TICKET ? ticket : undefined,
@@ -48,10 +59,10 @@ export default function PassportVerify({ className }) {
48 as={TicketInput}
59 as={TicketInput}
49 name="ticket"
60 name="ticket"
50 label="Master Ticket"
61 label="Master Ticket"
51− validators={validators}
52− autoFocus
53 />
62 />
54
63
64+ <Grid.Item full as={FormError} />
65+
55 <Grid.Item
66 <Grid.Item
56 full
67 full
57 className="mt3"
68 className="mt3"
src/views/Login.js+4−18
@@ -68,8 +68,6 @@ export default function Login() {
68 const { wallet, walletType } = useWallet();
68 const { wallet, walletType } = useWallet();
69 const { pointCursor, setPointCursor } = usePointCursor();
69 const { pointCursor, setPointCursor } = usePointCursor();
70
70
71− const [deducing, setDeducing] = useState(false);
72−
73 // inputs
71 // inputs
74 const [currentTab, setCurrentTab] = useState(
72 const [currentTab, setCurrentTab] = useState(
75 walletTypeToViewName(walletType)
73 walletTypeToViewName(walletType)
@@ -89,12 +87,10 @@ export default function Login() {
89 push(names.POINT);
87 push(names.POINT);
90 }, [goToPoints, push, names]);
88 }, [goToPoints, push, names]);
91
89
92− const doContinue = useCallback(async () => {
90+ const goHome = useCallback(async () => {
93 const _wallet = need.wallet(wallet);
91 const _wallet = need.wallet(wallet);
94 const _contracts = need.contracts(contracts);
92 const _contracts = need.contracts(contracts);
95
93
96− setDeducing(true);
97−
98 let deduced = pointCursor;
94 let deduced = pointCursor;
99 // if no point cursor set by login logic, try to deduce it
95 // if no point cursor set by login logic, try to deduce it
100 if (Nothing.hasInstance(deduced)) {
96 if (Nothing.hasInstance(deduced)) {
@@ -107,8 +103,6 @@ export default function Login() {
107 }
103 }
108 }
104 }
109
105
110− setDeducing(false);
111−
112 // if we have a deduced point or one in the global context,
106 // if we have a deduced point or one in the global context,
113 // navigate to that specific point, otherwise navigate to list of points
107 // navigate to that specific point, otherwise navigate to list of points
114 if (Just.hasInstance(deduced)) {
108 if (Just.hasInstance(deduced)) {
@@ -131,22 +125,14 @@ export default function Login() {
131 full
125 full
132 as={Tabs}
126 as={Tabs}
133 className="mt1"
127 className="mt1"
128+ // Tabs
134 views={VIEWS}
129 views={VIEWS}
135 options={OPTIONS}
130 options={OPTIONS}
136 currentTab={currentTab}
131 currentTab={currentTab}
137 onTabChange={setCurrentTab}
132 onTabChange={setCurrentTab}
133+ // Tab extra
134+ goHome={goHome}
138 />
135 />
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>
150 </Grid>
136 </Grid>
151
137
152 <Footer>
138 <Footer>
src/views/Login/Advanced.js+2−2
@@ -20,7 +20,7 @@ const OPTIONS = [
20 { text: 'Ethereum Keystore', value: NAMES.KEYSTORE },
20 { text: 'Ethereum Keystore', value: NAMES.KEYSTORE },
21];
21];
22
22
23−export default function Advanced({ loginCompleted, className }) {
23+export default function Advanced({ className, ...rest }) {
24 const [currentTab, setCurrentTab] = useState(undefined);
24 const [currentTab, setCurrentTab] = useState(undefined);
25
25
26 return (
26 return (
@@ -31,7 +31,7 @@ export default function Advanced({ loginCompleted, className }) {
31 currentTab={currentTab}
31 currentTab={currentTab}
32 onTabChange={setCurrentTab}
32 onTabChange={setCurrentTab}
33 //
33 //
34− loginCompleted={loginCompleted}
34+ {...rest}
35 />
35 />
36 );
36 );
37}
37}
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 = [
20 { text: 'Trezor', value: NAMES.TREZOR },
20 { text: 'Trezor', value: NAMES.TREZOR },
21];
21];
22
22
23−export default function Hardware({ loginCompleted, className }) {
23+export default function Hardware({ className, ...rest }) {
24 const [currentTab, setCurrentTab] = useState(undefined);
24 const [currentTab, setCurrentTab] = useState(undefined);
25
25
26 return (
26 return (
@@ -31,7 +31,7 @@ export default function Hardware({ loginCompleted, className }) {
31 currentTab={currentTab}
31 currentTab={currentTab}
32 onTabChange={setCurrentTab}
32 onTabChange={setCurrentTab}
33 //
33 //
34− loginCompleted={loginCompleted}
34+ {...rest}
35 />
35 />
36 );
36 );
37}
37}
src/views/Login/Keystore.js+66−73
@@ -1,17 +1,24 @@
1−import React, { useState } from 'react';
1+import React, { useCallback, useMemo } from 'react';
2−import { Just, Nothing } from 'folktale/maybe';
2+import { Just } from 'folktale/maybe';
3−import { P, Grid, Input, ErrorText } from 'indigo-react';
3+import { P, Grid } from 'indigo-react';
4import * as keythereum from 'keythereum';
4import * as keythereum from 'keythereum';
5
5
6import { useWallet } from 'store/wallet';
6import { useWallet } from 'store/wallet';
7
7
8−import { usePassphraseInput } from 'lib/useInputs';
9−import * as need from 'lib/need';
10import { EthereumWallet, WALLET_TYPES } from 'lib/wallet';
8import { EthereumWallet, WALLET_TYPES } from 'lib/wallet';
11import useLoginView from 'lib/useLoginView';
9import useLoginView from 'lib/useLoginView';
12
10
13−import { ForwardButton } from 'components/Buttons';
11+import {
14−import UploadButton from 'components/UploadButton';
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';
15
22
16export default function Keystore({ className }) {
23export default function Keystore({ className }) {
17 useLoginView(WALLET_TYPES.KEYSTORE);
24 useLoginView(WALLET_TYPES.KEYSTORE);
@@ -19,52 +26,33 @@ export default function Keystore({ className }) {
19 // globals
26 // globals
20 const { setWallet } = useWallet();
27 const { setWallet } = useWallet();
21
28
22− const [error, setError] = useState();
29+ const validate = useMemo(
23− // inputs
30+ () =>
24− // keystore: Maybe<String>
31+ composeValidator({
25− const [keystore, setKeystore] = useState(Nothing());
32+ passphrase: buildPassphraseValidator(),
26− const [passphraseInput, { data: passphrase }] = usePassphraseInput({
33+ keystore: buildUploadValidator(),
27− name: 'password',
34+ }),
28− label: 'Keystore password',
35+ []
29− autoFocus: true,
36+ );
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;
65
37
66− reader.readAsText(file);
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+ }
67− };
53+ },
54+ [setWallet]
55+ );
68
56
69 return (
57 return (
70 <Grid className={className}>
58 <Grid className={className}>
@@ -73,27 +61,32 @@ export default function Keystore({ className }) {
73 encrypted with a password, you'll also need to enter that below.
61 encrypted with a password, you'll also need to enter that below.
74 </Grid.Item>
62 </Grid.Item>
75
63
76− <Grid.Item full as={UploadButton} onChange={handleKeystoreUpload}>
64+ <BridgeForm validate={validate} onSubmit={onSubmit}>
65+ {({ handleSubmit }) => (
66+ <>
67+ <Grid.Item
68+ full
69+ as={UploadInput}
70+ name="keystore"
77− Upload Keystore file
71+ label="Upload Keystore file"
78− </Grid.Item>
72+ />
79−
73+
80− {error && (
74+ <Grid.Item
81− <Grid.Item full as={ErrorText} className="mt1">
75+ full
82− {error}
76+ as={PassphraseInput}
83− </Grid.Item>
77+ className="mt3"
84− )}
78+ name="passphrase"
85−
79+ label="Passphrase"
80+ />
81+
86− <Grid.Item full as={Input} className="mt3" {...passphraseInput} />
82+ <Grid.Item full as={FormError} />
87−
83+
88− <Grid.Item
84+ <Grid.Item full as={ContinueButton} handleSubmit={handleSubmit}>
89− full
90− as={ForwardButton}
91− solid
92− className="mt3"
93− disabled={Nothing.hasInstance(keystore)}
94− onClick={constructWallet}>
95− Decrypt
85+ Decrypt
96− </Grid.Item>
86+ </Grid.Item>
87+ </>
88+ )}
89+ </BridgeForm>
97 </Grid>
90 </Grid>
98 );
91 );
99}
92}
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';
2import cn from 'classnames';
2import cn from 'classnames';
3import { Just, Nothing } from 'folktale/maybe';
3import { Just, Nothing } from 'folktale/maybe';
4import {
4import {
@@ -18,14 +18,6 @@ import * as secp256k1 from 'secp256k1';
18
18
19import { useWallet } from 'store/wallet';
19import { useWallet } from 'store/wallet';
20
20
21−import { ForwardButton } from 'components/Buttons';
22−
23−import {
24− useCheckboxInput,
25− useHdPathInput,
26− useSelectInput,
27−} from 'lib/useInputs';
28−
29import {
21import {
30 LEDGER_LIVE_PATH,
22 LEDGER_LIVE_PATH,
31 LEDGER_LEGACY_PATH,
23 LEDGER_LEGACY_PATH,
@@ -35,6 +27,16 @@ import {
35import { WALLET_TYPES } from 'lib/wallet';
27import { WALLET_TYPES } from 'lib/wallet';
36import useLoginView from 'lib/useLoginView';
28import useLoginView from 'lib/useLoginView';
37import useBreakpoints from 'lib/useBreakpoints';
29import 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';
38
40
39const PATH_OPTIONS = [
41const PATH_OPTIONS = [
40 { text: 'Ledger Live', value: LEDGER_LIVE_PATH },
42 { text: 'Ledger Live', value: LEDGER_LIVE_PATH },
@@ -46,74 +48,60 @@ const ACCOUNT_OPTIONS = times(20, i => ({
46 value: i,
48 value: i,
47}));
49}));
48
50
49−export default function Ledger({ className }) {
51+export default function Ledger({ className, goHome }) {
50 useLoginView(WALLET_TYPES.LEDGER);
52 useLoginView(WALLET_TYPES.LEDGER);
51
53
52 const { setWallet, setWalletHdPath } = useWallet();
54 const { setWallet, setWalletHdPath } = useWallet();
53
55
54− // derivation path input
56+ const validate = useMemo(
55− const [derivationPathInput, { data: basePathPattern }] = useSelectInput({
57+ () =>
58+ composeValidator({
59+ useCustomPath: buildCheckboxValidator(),
56− name: 'derivationpath',
60+ derivationpath: buildSelectValidator(PATH_OPTIONS),
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,
61+ account: buildSelectValidator(ACCOUNT_OPTIONS),
68− });
62+ hdpath: buildHdPathValidator(),
69−
63+ }),
70− // custom toggle
64+ []
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− });
65+ );
88−
66+
89− const pollDevice = useCallback(async () => {
67+ const onSubmit = useCallback(
68+ async values => {
90− const transport = await Transport.create();
69+ const transport = await Transport.create();
91− const eth = new Eth(transport);
70+ const eth = new Eth(transport);
92− const path = chopHdPrefix(hdPath);
71+ const path = chopHdPrefix(values.hdpath);
93−
72+
94− try {
73+ try {
95− const info = await eth.getAddress(path, false, true);
74+ const info = await eth.getAddress(path, false, true);
96− const publicKey = Buffer.from(info.publicKey, 'hex');
75+ const publicKey = Buffer.from(info.publicKey, 'hex');
97− const chainCode = Buffer.from(info.chainCode, 'hex');
76+ const chainCode = Buffer.from(info.chainCode, 'hex');
98− const pub = secp256k1.publicKeyConvert(publicKey, true);
77+ const pub = secp256k1.publicKeyConvert(publicKey, true);
99− const hd = bip32.fromPublicKey(pub, chainCode);
78+ const hd = bip32.fromPublicKey(pub, chainCode);
100− setWallet(Just(hd));
79+ setWallet(Just(hd));
101− setWalletHdPath(addHdPrefix(hdPath));
80+ setWalletHdPath(addHdPrefix(values.hdpath));
102− } catch (error) {
81+ } catch (error) {
103− console.error(error);
82+ console.error(error);
104− setWallet(Nothing());
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;
105 }
94 }
106− }, [hdPath, setWallet, setWalletHdPath]);
95+
107−
108− // when the base path pattern or the account index changes
96+ // when the base path pattern or the account index changes
109− // update the hd path in our input
97+ // update the hd path in our input
110− useEffect(() => {
111− if (useCustomPath) {
98+ if (!values.useCustomPath) {
112− // updated by useForm
99+ form.change(
113− } else {
100+ 'hdpath',
114− setHdPath(basePathPattern.replace(/x/g, accountIndex));
101+ values.derivationpath.replace(/x/g, values.account)
102+ );
115 }
103 }
116− }, [useCustomPath, setHdPath, basePathPattern, accountIndex]);
104+ }, []);
117
105
118 const full = useBreakpoints([true, true, false]);
106 const full = useBreakpoints([true, true, false]);
119 const half = useBreakpoints([false, false, true]);
107 const half = useBreakpoints([false, false, true]);
@@ -170,37 +158,62 @@ export default function Ledger({ className }) {
170 enable the "contract data" option.
158 enable the "contract data" option.
171 </Grid.Item>
159 </Grid.Item>
172
160
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}>
173− {useCustomPath && <Grid.Item full as={Input} {...hdPathInput} />}
174+ <Grid.Item full as={Input} name="hdpath" label="HD Path" />
174−
175+ </Condition>
176+
175− {!useCustomPath && (
177+ <Condition when="useCustomPath" is={false}>
176− <>
177− <Grid.Item
178+ <Grid.Item
178− full={full}
179+ full={full}
179− half={half && 1}
180+ half={half && 1}
180− as={SelectInput}
181+ as={SelectInput}
181− className={cn({ pr1: half })}
182+ className={cn({ pr1: half })}
182− {...derivationPathInput}
183+ name="derivationpath"
184+ label="Derivation Path"
185+ placeholder="Choose path pattern..."
186+ options={PATH_OPTIONS}
183− />
187+ />
188+
184− <Grid.Item
189+ <Grid.Item
185− full={full}
190+ full={full}
186− half={half && 2}
191+ half={half && 2}
187− as={SelectInput}
192+ as={SelectInput}
188− className={cn({ pl1: half })}
193+ className={cn({ pl1: half })}
189− {...accountInput}
194+ name="account"
195+ label="Account"
196+ placeholder="Choose account..."
197+ options={ACCOUNT_OPTIONS}
190− />
198+ />
191− </>
199+ </Condition>
192− )}
200+
193−
201+ <Grid.Item
202+ full
194− <Grid.Item full as={CheckboxInput} className="mv3" {...customPathInput} />
203+ as={CheckboxInput}
195−
204+ className="mv3"
205+ name="useCustomPath"
206+ label="Custom HD Path"
207+ />
208+
196− <Grid.Item
209+ <Grid.Item full as={FormError} />
197− full
210+
198− as={ForwardButton}
211+ <Grid.Item full as={ContinueButton} handleSubmit={handleSubmit}>
199− solid
200− className="mt2"
201− onClick={pollDevice}>
202− Authenticate
212+ Authenticate
203− </Grid.Item>
213+ </Grid.Item>
214+ </>
215+ )}
216+ </BridgeForm>
204 </>
217 </>
205 );
218 );
206
219
src/views/Login/Mnemonic.js+36−24
@@ -1,4 +1,4 @@
1−import React, { useCallback } from 'react';
1+import React, { useCallback, useMemo } from 'react';
2import cn from 'classnames';
2import cn from 'classnames';
3import { Just, Nothing } from 'folktale/maybe';
3import { Just, Nothing } from 'folktale/maybe';
4import { Grid, CheckboxInput } from 'indigo-react';
4import { Grid, CheckboxInput } from 'indigo-react';
@@ -7,11 +7,22 @@ import { useWallet } from 'store/wallet';
7
7
8import { walletFromMnemonic, WALLET_TYPES } from 'lib/wallet';
8import { walletFromMnemonic, WALLET_TYPES } from 'lib/wallet';
9import useLoginView from 'lib/useLoginView';
9import useLoginView from 'lib/useLoginView';
10+import {
11+ MnemonicInput,
12+ HdPathInput,
10−import { MnemonicInput, HdPathInput, PassphraseInput } from 'form/Inputs';
13+ PassphraseInput,
14+ composeValidator,
15+ buildMnemonicValidator,
16+ buildCheckboxValidator,
17+ buildPassphraseValidator,
18+ buildHdPathValidator,
19+} from 'form/Inputs';
11import BridgeForm from 'form/BridgeForm';
20import BridgeForm from 'form/BridgeForm';
12import Condition from 'form/Condition';
21import Condition from 'form/Condition';
22+import FormError from 'form/FormError';
23+import ContinueButton from './ContinueButton';
13
24
14−export default function Mnemonic({ className }) {
25+export default function Mnemonic({ className, goHome }) {
15 useLoginView(WALLET_TYPES.MNEMONIC);
26 useLoginView(WALLET_TYPES.MNEMONIC);
16
27
17 const {
28 const {
@@ -21,6 +32,17 @@ export default function Mnemonic({ className }) {
21 setWalletHdPath,
32 setWalletHdPath,
22 } = useWallet();
33 } = useWallet();
23
34
35+ const validate = useMemo(
36+ () =>
37+ composeValidator({
38+ useAdvanced: buildCheckboxValidator(),
39+ mnemonic: buildMnemonicValidator(),
40+ passphrase: buildPassphraseValidator(),
41+ hdpath: buildHdPathValidator(),
42+ }),
43+ []
44+ );
45+
24 // when the properties change, re-derive wallet and set global state
46 // when the properties change, re-derive wallet and set global state
25 const onValues = useCallback(
47 const onValues = useCallback(
26 ({ valid, values }) => {
48 ({ valid, values }) => {
@@ -41,10 +63,11 @@ export default function Mnemonic({ className }) {
41 return (
63 return (
42 <Grid className={cn('mt4', className)}>
64 <Grid className={cn('mt4', className)}>
43 <BridgeForm
65 <BridgeForm
66+ validate={validate}
44 onValues={onValues}
67 onValues={onValues}
45− onSubmit={() => undefined}
68+ onSubmit={goHome}
46− initialValues={{ hdpath: walletHdPath }}>
69+ initialValues={{ hdpath: walletHdPath, useAdvanced: false }}>
47− {() => (
70+ {({ handleSubmit }) => (
48 <>
71 <>
49 <Grid.Item
72 <Grid.Item
50 full
73 full
@@ -53,7 +76,7 @@ export default function Mnemonic({ className }) {
53 label="BIP39 Mnemonic"
76 label="BIP39 Mnemonic"
54 />
77 />
55
78
56− <Condition when="advanced" is={true}>
79+ <Condition when="useAdvanced" is={true}>
57 <Grid.Item
80 <Grid.Item
58 full
81 full
59 as={PassphraseInput}
82 as={PassphraseInput}
@@ -61,33 +84,22 @@ export default function Mnemonic({ className }) {
61 label="Passphrase"
84 label="Passphrase"
62 />
85 />
63
86
64− <Grid.Item
65− full
66− as={HdPathInput}
87+ <Grid.Item full as={HdPathInput} name="hdpath" label="HD Path" />
67− name="hdpath"
68− label="HD Path"
69− autoFocus
70− />
71 </Condition>
88 </Condition>
72
89
73 <Grid.Item
90 <Grid.Item
74 full
91 full
75 as={CheckboxInput}
92 as={CheckboxInput}
76− name="advanced"
93+ name="useAdvanced"
77 label="Passphrase & HD Path"
94 label="Passphrase & HD Path"
78 />
95 />
96+
97+ <Grid.Item full as={FormError} />
98+
99+ <Grid.Item full as={ContinueButton} handleSubmit={handleSubmit} />
79 </>
100 </>
80 )}
101 )}
81 </BridgeForm>
102 </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 /> */}
91 </Grid>
103 </Grid>
92 );
104 );
93}
105}
src/views/Login/PrivateKey.js+44−20
@@ -1,38 +1,62 @@
1−import React, { useEffect } from 'react';
1+import React, { useCallback, useMemo } from 'react';
2import { Just, Nothing } from 'folktale/maybe';
2import { Just, Nothing } from 'folktale/maybe';
3−import { Grid, Input } from 'indigo-react';
3+import { Grid } from 'indigo-react';
4
4
5import { useWallet } from 'store/wallet';
5import { useWallet } from 'store/wallet';
6
6
7−import { useHexInput } from 'lib/useInputs';
8import { EthereumWallet, WALLET_TYPES, stripHexPrefix } from 'lib/wallet';
7import { EthereumWallet, WALLET_TYPES, stripHexPrefix } from 'lib/wallet';
9import useLoginView from 'lib/useLoginView';
8import 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';
10
17
11−export default function PrivateKey({ className }) {
18+export default function PrivateKey({ className, goHome }) {
12 useLoginView(WALLET_TYPES.PRIVATE_KEY);
19 useLoginView(WALLET_TYPES.PRIVATE_KEY);
13
20
14 const { setWallet } = useWallet();
21 const { setWallet } = useWallet();
15
22
16− const [privateKeyInput, { pass, data: privateKey }] = useHexInput({
23+ const validate = useMemo(
17− length: 64,
24+ () =>
18− name: 'privateKey',
25+ composeValidator({
19− label: 'Private key',
26+ privatekey: buildHexValidator(64),
20− autoFocus: true,
27+ }),
28+ []
21− });
29+ );
22
30
23− useEffect(() => {
31+ const onValues = useCallback(
24− if (pass) {
32+ ({ valid, values }) => {
33+ if (valid) {
25− const sec = Buffer.from(stripHexPrefix(privateKey), 'hex');
34+ const sec = Buffer.from(stripHexPrefix(values.privatekey), 'hex');
26− const newWallet = new EthereumWallet(sec);
35+ const newWallet = new EthereumWallet(sec);
27− setWallet(Just(newWallet));
36+ setWallet(Just(newWallet));
28− } else {
37+ } else {
29− setWallet(Nothing());
38+ setWallet(Nothing());
30− }
39+ }
40+ },
31− }, [pass, privateKey, setWallet]);
41+ [setWallet]
42+ );
32
43
33 return (
44 return (
34 <Grid className={className}>
45 <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>
36 </Grid>
60 </Grid>
37 );
61 );
38}
62}
src/views/Login/Ticket.js+181−159
@@ -1,28 +1,37 @@
1+import React, { useCallback, useMemo, useRef } from 'react';
1import { Just, Nothing } from 'folktale/maybe';
2import { Just, Nothing } from 'folktale/maybe';
2import cn from 'classnames';
3import cn from 'classnames';
3−import React, { useCallback, useState, useEffect } from 'react';
4import * as azimuth from 'azimuth-js';
4import * as azimuth from 'azimuth-js';
5−import * as ob from 'urbit-ob';
6import * as kg from 'urbit-key-generation/dist/index';
5import * 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';
8
8
9import { useNetwork } from 'store/network';
9import { useNetwork } from 'store/network';
10import { useWallet } from 'store/wallet';
10import { useWallet } from 'store/wallet';
11import { usePointCursor } from 'store/pointCursor';
11import { usePointCursor } from 'store/pointCursor';
12
12
13−import {
14− usePointInput,
15− useTicketInput,
16− usePassphraseInput,
17− useCheckboxInput,
18−} from 'lib/useInputs';
19import * as need from 'lib/need';
13import * as need from 'lib/need';
20import { WALLET_TYPES, urbitWalletFromTicket } from 'lib/wallet';
14import { WALLET_TYPES, urbitWalletFromTicket } from 'lib/wallet';
21import useImpliedPoint from 'lib/useImpliedPoint';
15import useImpliedPoint from 'lib/useImpliedPoint';
22import useLoginView from 'lib/useLoginView';
16import useLoginView from 'lib/useLoginView';
23import patp2dec from 'lib/patp2dec';
17import patp2dec from 'lib/patp2dec';
24−
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+
25−export default function Ticket({ className }) {
34+export default function Ticket({ className, goHome }) {
26 useLoginView(WALLET_TYPES.TICKET);
35 useLoginView(WALLET_TYPES.TICKET);
27
36
28 const { contracts } = useNetwork();
37 const { contracts } = useNetwork();
@@ -30,158 +39,171 @@ export default function Ticket({ className }) {
30 const { setPointCursor } = usePointCursor();
39 const { setPointCursor } = usePointCursor();
31 const impliedPoint = useImpliedPoint();
40 const impliedPoint = useImpliedPoint();
32
41
33− // point
42+ const cachedUrbitWallet = useRef(Nothing());
34− const [pointInput, { data: pointName }] = usePointInput({
43+
35− name: 'point',
44+ const validateForm = useCallback(
36− initialValue: impliedPoint || '',
45+ async (values, errors) => {
37− autoFocus: true,
46+ if (errors.point) {
38− });
47+ return errors;
39−
48+ }
40− // passphrase
49+
41− const [passphraseInput, { data: passphrase }] = usePassphraseInput({
50+ if (values.useShards) {
42− name: 'passphrase',
51+ if (errors.shard1 || errors.shard2 || errors.shard3) {
43− label: 'Wallet Passphrase',
52+ return errors;
44− });
53+ }
45−
54+
46− const [hasPassphraseInput, { data: hasPassphrase }] = useCheckboxInput({
55+ // shards
47− name: 'has-passphrase',
56+ try {
48− label: 'Passphrase',
57+ const ticket = kg.combine([
49− initialValue: false,
58+ values.shard1,
50− });
59+ values.shard2,
51−
60+ values.shard3,
52− // ticket
61+ ]);
53− const [error, setError] = useState();
62+ const point = patp2dec(values.point);
54− const [deriving, setDeriving] = useState(false);
63+ cachedUrbitWallet.current = await urbitWalletFromTicket(
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 ||
64+ ticket,
95− !pointName ||
65+ point,
96− !ob.isValidPatq(ticket) ||
66+ values.passphrase
97− !ob.isValidPatp(pointName)
67+ );
98− ) {
68+ } catch (error) {
99− setDeriving(false);
69+ console.error(error);
70+ return { [FORM_ERROR]: 'Unable to derive wallet from shards.' };
71+ }
72+ } else {
73+ if (errors.ticket) {
100− return;
74+ return errors;
101− }
75+ }
102−
76+
77+ await timeout(100); // allow ui events to flush
78+ try {
79+ // ticket
103− const _contracts = need.contracts(contracts);
80+ const _contracts = need.contracts(contracts);
104− const point = patp2dec(pointName);
81+ const point = patp2dec(values.point);
82+
83+ console.log('computing...');
105− const urbitWallet = await urbitWalletFromTicket(ticket, point, passphrase);
84+ const urbitWallet = await urbitWalletFromTicket(
85+ values.ticket,
86+ point,
87+ values.passphrase
88+ );
89+
90+ cachedUrbitWallet.current = urbitWallet;
91+
106− const [isOwner, isTransferProxy] = await Promise.all([
92+ const [isOwner, isTransferProxy] = await Promise.all([
107− azimuth.azimuth.isOwner(
93+ azimuth.azimuth.isOwner(
108− _contracts,
94+ _contracts,
109− point,
95+ point,
110− urbitWallet.ownership.keys.address
96+ urbitWallet.ownership.keys.address
111− ),
97+ ),
112− azimuth.azimuth.isTransferProxy(
98+ azimuth.azimuth.isTransferProxy(
113− _contracts,
99+ _contracts,
114− point,
100+ point,
115− urbitWallet.ownership.keys.address
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
116 ),
133 ),
117− ]);
134+ [validateForm]
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− ]);
135+ );
138−
136+
139− const deriveWalletFromShards = useCallback(async () => {
137+ const onValues = useCallback(
140− const s1 = shard1 || undefined;
138+ ({ valid, values }) => {
141− const s2 = shard2 || undefined;
139+ if (valid) {
142− const s3 = shard3 || undefined;
140+ setUrbitWallet(Just(cachedUrbitWallet.current));
143−
141+ setPointCursor(Just(patp2dec(values.point)));
144− try {
142+ } else {
145− const ticket = kg.combine([s1, s2, s3]);
143+ setUrbitWallet(Nothing());
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− }
144+ }
161− }, [
145+ },
162− isUsingShards,
146+ [setPointCursor, setUrbitWallet]
163− validTicket,
164− shardsReady,
165− deriveWalletFromShards,
166− deriveWalletFromTicket,
167− ]);
147+ );
168
148
169 return (
149 return (
170 <Grid className={cn('mt4', className)}>
150 <Grid className={cn('mt4', className)}>
171− <Grid.Item full as={Input} {...pointInput} />
151+ <BridgeForm
172−
152+ validate={validate}
173− {!isUsingShards && <Grid.Item full as={Input} {...ticketInput} />}
153+ onValues={onValues}
174− {isUsingShards && (
154+ onSubmit={goHome}
155+ initialValues={{
156+ point: impliedPoint || '',
157+ usePasshrase: false,
158+ useShards: false,
159+ }}>
160+ {({ handleSubmit }) => (
175− <>
161+ <>
176− <Grid.Item full as={Input} {...shard1Input} />
162+ <Grid.Item full as={PointInput} name="point" />
177− <Grid.Item full as={Input} {...shard2Input} />
163+
178− <Grid.Item full as={Input} {...shard3Input} />
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} />
179− </>
204+ </>
180− )}
205+ )}
181− {hasPassphrase && <Grid.Item full as={Input} {...passphraseInput} />}
206+ </BridgeForm>
182−
183− <Grid.Item full as={CheckboxInput} {...hasPassphraseInput} />
184− <Grid.Item full as={CheckboxInput} {...shardsInput} />
185 </Grid>
207 </Grid>
186 );
208 );
187}
209}
src/views/Login/Trezor.js+95−75
@@ -1,4 +1,4 @@
1−import React, { useEffect } from 'react';
1+import React, { useCallback, useMemo } from 'react';
2import { Just, Nothing } from 'folktale/maybe';
2import { Just, Nothing } from 'folktale/maybe';
3import * as bip32 from 'bip32';
3import * as bip32 from 'bip32';
4import { times } from 'lodash';
4import { times } from 'lodash';
@@ -18,13 +18,17 @@ import { useWallet } from 'store/wallet';
18import { TREZOR_PATH } from 'lib/trezor';
18import { TREZOR_PATH } from 'lib/trezor';
19import { WALLET_TYPES } from 'lib/wallet';
19import { WALLET_TYPES } from 'lib/wallet';
20import useLoginView from 'lib/useLoginView';
20import useLoginView from 'lib/useLoginView';
21−import {
22− useHdPathInput,
23− useCheckboxInput,
24− useSelectInput,
25−} from 'lib/useInputs';
26
21
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';
28
32
29const ACCOUNT_OPTIONS = times(20, i => ({
33const ACCOUNT_OPTIONS = times(20, i => ({
30 text: `Account #${i + 1}`,
34 text: `Account #${i + 1}`,
@@ -37,63 +41,51 @@ export default function Trezor({ className }) {
37
41
38 const { setWallet, setWalletHdPath } = useWallet();
42 const { setWallet, setWalletHdPath } = useWallet();
39
43
40− // custom toggle
44+ const validate = useMemo(
41− const [customPathInput, { data: useCustomPath }] = useCheckboxInput({
45+ () =>
42− name: 'customPath',
46+ composeValidator({
43− label: 'Custom HD Path',
47+ useCustomPath: buildCheckboxValidator(),
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,
48+ account: buildSelectValidator(ACCOUNT_OPTIONS),
54− });
49+ hdpath: buildHdPathValidator(),
55−
50+ }),
56− // hd path input
51+ []
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− });
52+ );
66−
53+
67− const pollDevice = async () => {
54+ const onSubmit = useCallback(
55+ async values => {
68− TrezorConnect.manifest({
56+ TrezorConnect.manifest({
69− email: 'bridge-trezor@urbit.org',
57+ email: 'bridge-trezor@urbit.org',
70− appUrl: 'https://github.com/urbit/bridge',
58+ appUrl: 'https://github.com/urbit/bridge',
71− });
59+ });
72−
60+
73− const info = await TrezorConnect.getPublicKey({
61+ const info = await TrezorConnect.getPublicKey({
74− path: hdPath,
62+ path: values.hdpath,
75− });
63+ });
76−
64+
77− if (info.success === true) {
65+ if (info.success === true) {
78− const payload = info.payload;
66+ const payload = info.payload;
79− const publicKey = Buffer.from(payload.publicKey, 'hex');
67+ const publicKey = Buffer.from(payload.publicKey, 'hex');
80− const chainCode = Buffer.from(payload.chainCode, 'hex');
68+ const chainCode = Buffer.from(payload.chainCode, 'hex');
81− const pub = secp256k1.publicKeyConvert(publicKey, true);
69+ const pub = secp256k1.publicKeyConvert(publicKey, true);
82− const hd = bip32.fromPublicKey(pub, chainCode);
70+ const hd = bip32.fromPublicKey(pub, chainCode);
83− setWallet(Just(hd));
71+ setWallet(Just(hd));
84− setWalletHdPath(hdPath);
72+ setWalletHdPath(values.hdPath);
85− } else {
73+ } else {
86− setWallet(Nothing());
74+ setWallet(Nothing());
75+ }
76+ },
77+ [setWallet, setWalletHdPath]
78+ );
79+
80+ const onValues = useCallback(({ valid, values, form }) => {
81+ if (!valid) {
82+ return;
87 }
83 }
88− };
89
84
90− useEffect(() => {
91− if (useCustomPath) {
85+ if (!values.useCustomPath) {
92− // updated by useForm
93− } else {
94− setHdPath(TREZOR_PATH.replace(/x/g, accountIndex));
86+ form.change('hdpath', TREZOR_PATH.replace(/x/g, values.account));
95 }
87 }
96− }, [useCustomPath, setHdPath, accountIndex]);
88+ }, []);
97
89
98 return (
90 return (
99 <Grid className={className}>
91 <Grid className={className}>
@@ -106,20 +98,48 @@ export default function Trezor({ className }) {
106 derivation path, you may enter it below.
98 derivation path, you may enter it below.
107 </Grid.Item>
99 </Grid.Item>
108
100
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}>
109− {useCustomPath && <Grid.Item full as={Input} {...hdPathInput} />}
113+ <Grid.Item full as={Input} name="hdpath" label="HD Path" />
110−
114+ </Condition>
115+
116+ <Condition when="useCustomPath" is={false}>
117+ <Grid.Item
118+ full
111− {!useCustomPath && <Grid.Item full as={SelectInput} {...accountInput} />}
119+ as={SelectInput}
112−
120+ name="account"
121+ label="Account"
122+ placeholder="Choose account..."
123+ options={ACCOUNT_OPTIONS}
124+ />
125+ </Condition>
126+
127+ <Grid.Item
128+ full
113− <Grid.Item full as={CheckboxInput} className="mv3" {...customPathInput} />
129+ as={CheckboxInput}
114−
130+ className="mv3"
131+ name="useCustomPath"
132+ label="Custom HD Path"
133+ />
134+
115− <Grid.Item
135+ <Grid.Item full as={FormError} />
116− full
136+
117− as={ForwardButton}
137+ <Grid.Item full as={ContinueButton} handleSubmit={handleSubmit}>
118− solid
119− className="mt2"
120− onClick={pollDevice}>
121− Authenticate
138+ Authenticate
122− </Grid.Item>
139+ </Grid.Item>
140+ </>
141+ )}
142+ </BridgeForm>
123 </Grid>
143 </Grid>
124 );
144 );
125}
145}