Skip to content

Commit

Permalink
frontend: group accounts by keystore in sidebar and account settings
Browse files Browse the repository at this point in the history
Group shows the keystore name.

The BitBox02 keystore name for now is simply the device name. For
passphrase users that means that multiple keystores will have the same
name. We will not give this any special consideration for now. The
user can label the individual accounts, which is enough to
disambiguate.
  • Loading branch information
benma committed Oct 24, 2023
1 parent 321ec56 commit 18aea1d
Show file tree
Hide file tree
Showing 7 changed files with 128 additions and 40 deletions.
19 changes: 14 additions & 5 deletions backend/config/accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,16 +104,25 @@ func (cfg AccountsConfig) Lookup(code accountsTypes.Code) *Account {
return nil
}

// GetOrAddKeystore looks up the keystore by root fingerprint. If it does not exist, one is added to
// the list of keystores and the newly created one is returned.
func (cfg *AccountsConfig) GetOrAddKeystore(rootFingerprint []byte) *Keystore {
// LookupKeystore looks up a keystore by fingerprint. Returns error if it could not be found.
func (cfg AccountsConfig) LookupKeystore(rootFingerprint []byte) (*Keystore, error) {
for _, ks := range cfg.Keystores {
if bytes.Equal(ks.RootFingerprint, rootFingerprint) {
return ks
return ks, nil
}
}
return nil, errp.Newf("could not retrieve keystore for fingerprint %x", rootFingerprint)
}

// GetOrAddKeystore looks up the keystore by root fingerprint. If it does not exist, one is added to
// the list of keystores and the newly created one is returned.
func (cfg *AccountsConfig) GetOrAddKeystore(rootFingerprint []byte) *Keystore {
ks, err := cfg.LookupKeystore(rootFingerprint)
if err == nil {
return ks
}

ks := &Keystore{RootFingerprint: rootFingerprint}
ks = &Keystore{RootFingerprint: rootFingerprint}
cfg.Keystores = append(cfg.Keystores, ks)
return ks
}
36 changes: 28 additions & 8 deletions backend/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,10 @@ type activeToken struct {
}

type accountJSON struct {
// Multiple accounts can belong to the same keystore. For now we replicate the keystore info in
// the accounts. In the future the getAccountsHandler() could return the accounts grouped
// keystore.
Keystore config.Keystore `json:"keystore"`
Active bool `json:"active"`
CoinCode coinpkg.Code `json:"coinCode"`
CoinUnit string `json:"coinUnit"`
Expand All @@ -352,10 +356,11 @@ type accountJSON struct {
BlockExplorerTxPrefix string `json:"blockExplorerTxPrefix"`
}

func newAccountJSON(account accounts.Interface, activeTokens []activeToken) *accountJSON {
func newAccountJSON(keystore config.Keystore, account accounts.Interface, activeTokens []activeToken) *accountJSON {
eth, ok := account.Coin().(*eth.Coin)
isToken := ok && eth.ERC20Token() != nil
return &accountJSON{
Keystore: keystore,
Active: !account.Config().Config.Inactive,
CoinCode: account.Coin().Code(),
CoinUnit: account.Coin().Unit(false),
Expand Down Expand Up @@ -515,27 +520,42 @@ func (handlers *Handlers) getKeystoresHandler(_ *http.Request) interface{} {
}

func (handlers *Handlers) getAccountsHandler(_ *http.Request) interface{} {
accounts := []*accountJSON{}
persistedAccounts := handlers.backend.Config().AccountsConfig()

accounts := []*accountJSON{}
for _, account := range handlers.backend.Accounts() {
if account.Config().Config.HiddenBecauseUnused {
continue
}
var activeTokens []activeToken

persistedAccount := persistedAccounts.Lookup(account.Config().Config.Code)
if persistedAccount == nil {
handlers.log.WithField("code", account.Config().Config.Code).Error("account not found in accounts database")
continue
}

if account.Coin().Code() == coinpkg.CodeETH {
persistedAccount := persistedAccounts.Lookup(account.Config().Config.Code)
if persistedAccount == nil {
handlers.log.WithField("code", account.Config().Config.Code).Error("account not found in accounts database")
continue
}
for _, tokenCode := range persistedAccount.ActiveTokens {
activeTokens = append(activeTokens, activeToken{
TokenCode: tokenCode,
AccountCode: backend.Erc20AccountCode(account.Config().Config.Code, tokenCode),
})
}
}
accounts = append(accounts, newAccountJSON(account, activeTokens))

rootFingerprint, err := persistedAccount.SigningConfigurations.RootFingerprint()
if err != nil {
handlers.log.WithField("code", account.Config().Config.Code).Error("could not identify root fingerprint")
continue
}
keystore, err := persistedAccounts.LookupKeystore(rootFingerprint)
if err != nil {
handlers.log.WithField("code", account.Config().Config.Code).Error("could not find keystore of account")
continue
}

accounts = append(accounts, newAccountJSON(*keystore, account, activeTokens))
}
return accounts
}
Expand Down
18 changes: 17 additions & 1 deletion backend/signing/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,9 +221,25 @@ func (configuration *Configuration) String() string {
return fmt.Sprintf("ethereumSimple;%s", configuration.EthereumSimple.KeyInfo)
}

// Configurations is an unordered collection of configurations.
// Configurations is an unordered collection of configurations. All entries must have the same root
// fingerprint.
type Configurations []*Configuration

// RootFingerprint gets the fingerprint of the first config (assuming that all configurations have
// the same rootFingerprint). Returns an error if the list has no entries or does not contain a
// known config.
func (configs Configurations) RootFingerprint() ([]byte, error) {
for _, config := range configs {
if config.BitcoinSimple != nil {
return config.BitcoinSimple.KeyInfo.RootFingerprint, nil
}
if config.EthereumSimple != nil {
return config.EthereumSimple.KeyInfo.RootFingerprint, nil
}
}
return nil, errp.New("Could not retrieve fingerprint from signing configurations")
}

// ContainsRootFingerprint returns true if the rootFingerprint is present in one of the configurations.
func (configs Configurations) ContainsRootFingerprint(rootFingerprint []byte) bool {
for _, config := range configs {
Expand Down
28 changes: 17 additions & 11 deletions frontends/web/src/api/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,20 +36,26 @@ export type Terc20Token = {
};

export interface IActiveToken {
tokenCode: string;
accountCode: AccountCode;
tokenCode: string;
accountCode: AccountCode;
}

export type TKeystore = {
rootFingerprint: string;
name: string;
};

export interface IAccount {
active: boolean;
coinCode: CoinCode;
coinUnit: string;
coinName: string;
code: AccountCode;
name: string;
isToken: boolean;
activeTokens?: IActiveToken[];
blockExplorerTxPrefix: string;
keystore: TKeystore;
active: boolean;
coinCode: CoinCode;
coinUnit: string;
coinName: string;
code: AccountCode;
name: string;
isToken: boolean;
activeTokens?: IActiveToken[];
blockExplorerTxPrefix: string;
}

export const getAccounts = (): Promise<IAccount[]> => {
Expand Down
22 changes: 15 additions & 7 deletions frontends/web/src/components/sidebar/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import { apiPost } from '../../utils/request';
import Logo, { AppLogoInverted } from '../icon/logo';
import { useLocation } from 'react-router';
import { CloseXWhite } from '../icon';
import { isBitcoinOnly } from '../../routes/account/utils';
import { getAccountsByKeystore, isBitcoinOnly } from '../../routes/account/utils';
import { SkipForTesting } from '../../routes/device/components/skipfortesting';
import { Store } from '../../decorators/store';
import style from './sidebar.module.css';
Expand Down Expand Up @@ -169,6 +169,7 @@ class Sidebar extends Component<Props> {
} = this.props;
const hidden = sidebarStatus === 'forceHidden';
const hasOnlyBTCAccounts = accounts.every(({ coinCode }) => isBitcoinOnly(coinCode));
const accountsByKeystore = getAccountsByKeystore(accounts);
return (
<div className={[style.sidebarContainer, hidden ? style.forceHide : ''].join(' ')}>
<div className={[style.sidebarOverlay, activeSidebar ? style.active : ''].join(' ')} onClick={toggleSidebar}></div>
Expand All @@ -184,11 +185,6 @@ class Sidebar extends Component<Props> {
</button>
</div>

<div className={style.sidebarHeaderContainer}>
<span className={style.sidebarHeader} hidden={!keystores?.length}>
{t('sidebar.accounts')}
</span>
</div>
{ accounts.length ? (
<div className={style.sidebarItem}>
<NavLink
Expand All @@ -203,7 +199,19 @@ class Sidebar extends Component<Props> {
</NavLink>
</div>
) : null }
{ accounts && accounts.map(acc => <GetAccountLink key={acc.code} {...acc} handleSidebarItemClick={this.handleSidebarItemClick }/>) }

{
accountsByKeystore.map(keystore => (<React.Fragment key={keystore.keystore.rootFingerprint}>
<div className={style.sidebarHeaderContainer}>
<span className={style.sidebarHeader} hidden={!keystores?.length}>
{t('sidebar.accounts')} - {keystore.keystore.name}
</span>
</div>

{ keystore.accounts.map(acc => <GetAccountLink key={acc.code} {...acc} handleSidebarItemClick={this.handleSidebarItemClick }/>) }
</React.Fragment>))
}

<div className={[style.sidebarHeaderContainer, style.end].join(' ')}></div>
{ accounts.length ? (
<div key="buy" className={style.sidebarItem}>
Expand Down
22 changes: 21 additions & 1 deletion frontends/web/src/routes/account/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* limitations under the License.
*/

import { CoinCode, ScriptType, IAccount, CoinUnit } from '../../api/account';
import { CoinCode, ScriptType, IAccount, CoinUnit, TKeystore } from '../../api/account';

export function findAccount(accounts: IAccount[], accountCode: string): IAccount | undefined {
return accounts.find(({ code }) => accountCode === code);
Expand Down Expand Up @@ -94,3 +94,23 @@ export function customFeeUnit(coinCode: CoinCode): string {
}
return '';
}

export type TAccountsByKeystore = {
keystore: TKeystore;
accounts: IAccount[];
};

// Returns the accounts grouped by the keystore fingerprint.
export function getAccountsByKeystore(accounts: IAccount[]): TAccountsByKeystore[] {
return Object.values(accounts.reduce((acc, account) => {
const key = account.keystore.rootFingerprint;
if (!acc[key]) {
acc[key] = {
keystore: account.keystore,
accounts: []
};
}
acc[key].accounts.push(account);
return acc;
}, {} as Record<string, TAccountsByKeystore>));
}
23 changes: 16 additions & 7 deletions frontends/web/src/routes/settings/manage-accounts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/

import React, { Component } from 'react';
import { getAccountsByKeystore } from '../account/utils';
import { route } from '../../utils/route';
import * as accountAPI from '../../api/account';
import * as backendAPI from '../../api/backend';
Expand Down Expand Up @@ -68,8 +69,8 @@ class ManageAccounts extends Component<Props, State> {
this.fetchAccounts();
}

private renderAccounts = () => {
const { accounts, showTokens } = this.state;
private renderAccounts = (accounts: accountAPI.IAccount[]) => {
const { showTokens } = this.state;
const { t } = this.props;
return accounts.filter(account => !account.isToken).map(account => {
const active = account.active;
Expand Down Expand Up @@ -216,8 +217,8 @@ class ManageAccounts extends Component<Props, State> {

public render() {
const { t, deviceIDs, hasAccounts } = this.props;
const { editAccountCode, editAccountNewName, editErrorMessage } = this.state;
const accountList = this.renderAccounts();
const { accounts, editAccountCode, editAccountNewName, editErrorMessage } = this.state;
const accountsByKeystore = getAccountsByKeystore(accounts);
return (
<GuideWrapper>
<GuidedContent>
Expand All @@ -240,9 +241,17 @@ class ManageAccounts extends Component<Props, State> {
onClick={() => route('/add-account', true)}>
{t('addAccount.title')}
</Button>
<div className="box slim divide m-bottom-large">
{ (accountList && accountList.length) ? accountList : t('manageAccounts.noAccounts') }
</div>

{
accountsByKeystore.map(keystore => (<React.Fragment key={keystore.keystore.rootFingerprint}>
<p>{keystore.keystore.name}</p>
<div className="box slim divide m-bottom-large">
{ this.renderAccounts(keystore.accounts) }
</div>
</React.Fragment>))
}
{ accounts.length === 0 ? t('manageAccounts.noAccounts') : null }

<Dialog
open={!!(editAccountCode)}
onClose={() => this.setState({ editAccountCode: undefined, editAccountNewName: '', editErrorMessage: undefined })}
Expand Down

0 comments on commit 18aea1d

Please sign in to comment.