Skip to content

Commit 2d7726c

Browse files
committed
frontend: add prompt to connect keystore
1 parent 2773f9b commit 2d7726c

File tree

8 files changed

+178
-5
lines changed

8 files changed

+178
-5
lines changed

backend/accounts.go

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -553,11 +553,64 @@ func (backend *Backend) createAndAddAccount(coin coinpkg.Coin, persistedConfig *
553553
DBFolder: backend.arguments.CacheDirectoryPath(),
554554
NotesFolder: backend.arguments.NotesDirectoryPath(),
555555
ConnectKeystore: func() (keystore.Keystore, error) {
556+
type data struct {
557+
Type string `json:"typ"`
558+
KeystoreName string `json:"keystoreName"`
559+
ErrorCode string `json:"errorCode"`
560+
ErrorMessage string `json:"errorMessage"`
561+
}
556562
accountRootFingerprint, err := persistedConfig.SigningConfigurations.RootFingerprint()
557563
if err != nil {
558564
return nil, err
559565
}
560-
return backend.connectKeystore.connect(backend.Keystore(), accountRootFingerprint, 20*time.Minute)
566+
keystoreName := ""
567+
persistedKeystore, err := backend.config.AccountsConfig().LookupKeystore(accountRootFingerprint)
568+
if err == nil {
569+
keystoreName = persistedKeystore.Name
570+
}
571+
backend.Notify(observable.Event{
572+
Subject: "connect-keystore",
573+
Action: action.Replace,
574+
Object: data{
575+
Type: "connect",
576+
KeystoreName: keystoreName,
577+
},
578+
})
579+
ks, err := backend.connectKeystore.connect(
580+
backend.Keystore(),
581+
accountRootFingerprint,
582+
20*time.Minute,
583+
)
584+
switch {
585+
case errp.Cause(err) == errReplaced:
586+
// If a previous connect-keystore request is in progress, the previous request is
587+
// failed, but we don't dismiss the prompt, as the new prompt has already been shown
588+
// by the above "connect" notification.y
589+
case err == nil || errp.Cause(err) == errUserAbort:
590+
// Dismiss prompt after success or upon user abort.
591+
592+
backend.Notify(observable.Event{
593+
Subject: "connect-keystore",
594+
Action: action.Replace,
595+
Object: nil,
596+
})
597+
default:
598+
// Display error to user.
599+
errorCode := ""
600+
if errp.Cause(err) == errWrongKeystore {
601+
errorCode = "wrongKeystore"
602+
}
603+
backend.Notify(observable.Event{
604+
Subject: "connect-keystore",
605+
Action: action.Replace,
606+
Object: data{
607+
Type: "error",
608+
ErrorCode: errorCode,
609+
ErrorMessage: err.Error(),
610+
},
611+
})
612+
}
613+
return ks, err
561614
},
562615
OnEvent: func(event accountsTypes.Event) {
563616
backend.events <- AccountEvent{

backend/backend.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -784,3 +784,8 @@ func (backend *Backend) GetAccountFromCode(code string) (accounts.Interface, err
784784

785785
return acct, nil
786786
}
787+
788+
// CancelConnectKeystore cancels a pending keystore connection request if one exists.
789+
func (backend *Backend) CancelConnectKeystore() {
790+
backend.connectKeystore.cancel(errUserAbort)
791+
}

backend/handlers/handlers.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ type Backend interface {
103103
AOPPChooseAccount(code accountsTypes.Code)
104104
GetAccountFromCode(code string) (accounts.Interface, error)
105105
HTTPClient() *http.Client
106+
CancelConnectKeystore()
106107
}
107108

108109
// Handlers provides a web api to the backend.
@@ -232,6 +233,7 @@ func NewHandlers(
232233
getAPIRouterNoError(apiRouter)("/aopp/cancel", handlers.postAOPPCancelHandler).Methods("POST")
233234
getAPIRouterNoError(apiRouter)("/aopp/approve", handlers.postAOPPApproveHandler).Methods("POST")
234235
getAPIRouter(apiRouter)("/aopp/choose-account", handlers.postAOPPChooseAccountHandler).Methods("POST")
236+
getAPIRouter(apiRouter)("/cancel-connect-keystore", handlers.postCancelConnectKeystoreHandler).Methods("POST")
235237

236238
devicesRouter := getAPIRouterNoError(apiRouter.PathPrefix("/devices").Subrouter())
237239
devicesRouter("/registered", handlers.getDevicesRegisteredHandler).Methods("GET")
@@ -1276,3 +1278,8 @@ func (handlers *Handlers) postAOPPApproveHandler(r *http.Request) interface{} {
12761278
handlers.backend.AOPPApprove()
12771279
return nil
12781280
}
1281+
1282+
func (handlers *Handlers) postCancelConnectKeystoreHandler(r *http.Request) (interface{}, error) {
1283+
handlers.backend.CancelConnectKeystore()
1284+
return nil, nil
1285+
}

frontends/web/src/api/backend.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import { AccountCode, CoinCode } from './account';
1818
import { apiGet, apiPost } from '../utils/request';
1919
import { FailResponse, SuccessResponse } from './response';
20+
import { TSubscriptionCallback, subscribeEndpoint } from './subscribe';
2021

2122
export interface ICoin {
2223
coinCode: CoinCode;
@@ -74,3 +75,32 @@ export const getDefaultConfig = (): Promise<any> => {
7475
export const socksProxyCheck = (proxyAddress: string): Promise<ISuccess> => {
7576
return apiPost('socksproxy/check', proxyAddress);
7677
};
78+
79+
export type TSyncConnectKeystore = null | {
80+
typ: 'connect';
81+
keystoreName: string;
82+
} | {
83+
typ: 'error';
84+
errorCode: 'wrongKeystore';
85+
errorMessage: '';
86+
};
87+
88+
/**
89+
* Returns a function that subscribes a callback on a "connect-keystore".
90+
* Meant to be used with `useSubscribe`.
91+
*/
92+
export const syncConnectKeystore = () => {
93+
return (
94+
cb: TSubscriptionCallback<TSyncConnectKeystore>
95+
) => {
96+
return subscribeEndpoint('connect-keystore', (
97+
obj: TSyncConnectKeystore,
98+
) => {
99+
cb(obj);
100+
});
101+
};
102+
};
103+
104+
export const cancelConnectKeystore = (): Promise<void> => {
105+
return apiPost('cancel-connect-keystore');
106+
};

frontends/web/src/app.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { Alert } from './components/alert/Alert';
3030
import { Aopp } from './components/aopp/aopp';
3131
import { Banner } from './components/banner/banner';
3232
import { Confirm } from './components/confirm/Confirm';
33+
import { KeystoreConnectPrompt } from './components/keystoreconnectprompt';
3334
import { panelStore } from './components/sidebar/sidebar';
3435
import { MobileDataWarning } from './components/mobiledatawarning';
3536
import { Sidebar, toggleSidebar } from './components/sidebar/sidebar';
@@ -193,6 +194,7 @@ class App extends Component<Props, State> {
193194
<Banner msgKey="bitbox02" />
194195
<MobileDataWarning />
195196
<Aopp />
197+
<KeystoreConnectPrompt />
196198
{
197199
Object.entries(devices).map(([deviceID, productName]) => {
198200
if (productName === 'bitbox02') {
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* Copyright 2023 Shift Crypto AG
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { useTranslation } from 'react-i18next';
18+
import { Button } from './forms';
19+
import { cancelConnectKeystore, syncConnectKeystore } from '../api/backend';
20+
import { useSubscribeReset } from '../hooks/api';
21+
import { SkipForTesting } from '../routes/device/components/skipfortesting';
22+
23+
export function KeystoreConnectPrompt() {
24+
const { t } = useTranslation();
25+
26+
const [data, reset] = useSubscribeReset(syncConnectKeystore());
27+
if (!data) {
28+
return null;
29+
}
30+
switch (data.typ) {
31+
case 'connect':
32+
// TODO: make this look nice.
33+
return (
34+
<>
35+
{ data.keystoreName === '' ?
36+
t('connectKeystore.promptNoName') :
37+
t('connectKeystore.promptWithName', { name: data.keystoreName })
38+
}
39+
{/* Software keystore is unlocked from the app, so we add the button here.
40+
The BitBox02 unlock is triggered by inserting it using the globally mounted BitBox02Wizard.
41+
Te BitBox01 is ignored - BitBox01 users will simply need to unlock before being prompted.
42+
*/}
43+
<SkipForTesting />
44+
<Button primary onClick={() => cancelConnectKeystore()}>{t('dialog.cancel')}</Button>
45+
</>
46+
);
47+
case 'error':
48+
return (
49+
<>
50+
{ data.errorCode === 'wrongKeystore' ? t('error.wrongKeystore') : data.errorMessage }
51+
<Button primary onClick={() => reset()}>{t('button.dismiss')}</Button>
52+
</>
53+
);
54+
default:
55+
return null;
56+
}
57+
}

frontends/web/src/hooks/api.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,15 @@ import { TSubscriptionCallback, TUnsubscribe } from '../api/subscribe';
1919
import { useMountedRef } from './mount';
2020

2121
/**
22-
* useSubscribe is a hook to subscribe to a subscription function.
22+
* useSubscribeReset is a hook to subscribe to a subscription function.
2323
* starts on first render, and returns undefined while there is no first response.
2424
* re-renders on every update.
25+
* An array is returned: `[value, reset]`, where value is the subscribed value and `reset()` resets
26+
* the value to `undefined`.
2527
*/
26-
export const useSubscribe = <T>(
28+
export const useSubscribeReset = <T>(
2729
subscription: ((callback: TSubscriptionCallback<T>) => TUnsubscribe),
28-
): (T | undefined) => {
30+
): [T | undefined, () => void] => {
2931
const [response, setResponse] = useState<T>();
3032
const mounted = useMountedRef();
3133
const subscribe = () => {
@@ -40,6 +42,18 @@ export const useSubscribe = <T>(
4042
// empty dependencies because it's only subscribed once
4143
[] // eslint-disable-line react-hooks/exhaustive-deps
4244
);
45+
return [response, () => setResponse(undefined)];
46+
};
47+
48+
/**
49+
* useSubscribe is a hook to subscribe to a subscription function.
50+
* starts on first render, and returns undefined while there is no first response.
51+
* re-renders on every update.
52+
*/
53+
export const useSubscribe = <T>(
54+
subscription: ((callback: TSubscriptionCallback<T>) => TUnsubscribe),
55+
): (T | undefined) => {
56+
const [response] = useSubscribeReset(subscription);
4357
return response;
4458
};
4559

frontends/web/src/locales/en/app.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,10 @@
487487
"infoWhenPaired": "First on the paired mobile and then your BitBox"
488488
},
489489
"confirmOnDevice": "Please confirm on your device.",
490+
"connectKeystore": {
491+
"promptNoName": "Please connect your BitBox02",
492+
"promptWithName": "Please connect your BitBox02 named \"{{name}}\""
493+
},
490494
"darkmode": {
491495
"toggle": "Dark mode"
492496
},
@@ -606,7 +610,8 @@
606610
"aoppUnsupportedAsset": "The asset is not supported.",
607611
"aoppUnsupportedFormat": "There are no available accounts that support the requested address format.",
608612
"aoppUnsupportedKeystore": "The connected device cannot sign messages for this asset.",
609-
"aoppVersion": "Unknown version."
613+
"aoppVersion": "Unknown version.",
614+
"wrongKeystore": "Wrong wallet connected - please make sure to insert the correct device matching this account. If you use the optional passphrase, make sure to use the passphrase matching this account."
610615
},
611616
"fiat": {
612617
"default": "default",

0 commit comments

Comments
 (0)