diff --git a/src/app/app.module.ts b/src/app/app.module.ts index e59a2157..c5439a7f 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -31,6 +31,7 @@ import { ErrorCallbackComponent } from './error-callback/error-callback.componen import { FreeDeSoDisclaimerComponent } from './free-deso-message/free-deso-disclaimer/free-deso-disclaimer.component'; import { FreeDesoMessageComponent } from './free-deso-message/free-deso-message.component'; import { GetDesoComponent } from './get-deso/get-deso.component'; +import { GroupedAccountSelectComponent } from './grouped-account-select/grouped-account-select.component'; import { HomeComponent } from './home/home.component'; import { IconsModule } from './icons/icons.module'; import { IdentityService } from './identity.service'; @@ -98,6 +99,7 @@ import { TransactionSpendingLimitComponent } from './transaction-spending-limit/ TransactionSpendingLimitAssociationComponent, TransactionSpendingLimitAccessGroupComponent, TransactionSpendingLimitAccessGroupMemberComponent, + GroupedAccountSelectComponent, ], imports: [ BrowserModule, diff --git a/src/app/grouped-account-select/account-number.ts b/src/app/grouped-account-select/account-number.ts new file mode 100644 index 00000000..56d8e717 --- /dev/null +++ b/src/app/grouped-account-select/account-number.ts @@ -0,0 +1,51 @@ +/** + * https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki#extended-keys + * The max value that should be used for an hd key account number is 2^31 -1. In + * practice, we would never reach this via pure incremental account number + * generation, but we allow entering arbitrary account numbers in the UI, so we + * need to explicitly validate them. For whatever reason, the hdkey library + * we use throws for anything over 2^30. + */ +const MAX_UNSIGNED_INT_VALUE = 1073741824; // 2^30 + +export function isValid32BitUnsignedInt(value: number) { + return Number.isInteger(value) && value >= 0 && value <= MAX_UNSIGNED_INT_VALUE; +} + +/** + * - The lowest possible sub-account number is 1 (0 is reserved for the "root" account). + * - If the set is empty, we just return 1. + * - Otherwise, we look for the highest number in the set and increment it by 1. + * - If the next incremented number is too big, we look back for the first gap in the numbers and use that instead. + * - If no gap is found (very unlikely), we throw an error. + */ +export function generateAccountNumber(accountNumbers: Set): number { + if (accountNumbers.size === 0) { + return 1; + } + + const sorted = Array.from(accountNumbers).sort((a, b) => a - b); + const currentHighestAccountNumber = sorted[sorted.length - 1]; + const candidate = currentHighestAccountNumber + 1; + + if (candidate <= MAX_UNSIGNED_INT_VALUE) { + return candidate; + } + + // At most we look back 500 numbers. This is a bit arbitrary... but the + // number of values could *technically* be 2^32 - 1, so we just limit the + // number of iterations to some reasonable value. The reason we look back for + // the highest available number instead of picking the lowest number is that + // the lowest number is more likely to have been used in the past and we're + // aiming to get a fresh wallet. + const maxLookBack = Math.max(sorted.length - 500, 0); + let nextExpectedValueInSequence = currentHighestAccountNumber - 1; + for (let i = sorted.length - 2; i >= maxLookBack; i--) { + if (nextExpectedValueInSequence !== sorted[i]) { + return nextExpectedValueInSequence; + } + nextExpectedValueInSequence--; + } + + throw new Error('Cannot generate account number.'); +} diff --git a/src/app/grouped-account-select/grouped-account-select.component.html b/src/app/grouped-account-select/grouped-account-select.component.html new file mode 100644 index 00000000..d02e72b8 --- /dev/null +++ b/src/app/grouped-account-select/grouped-account-select.component.html @@ -0,0 +1,157 @@ +
+ +
+
+ Select an account +
+ + +
+
    +
  • +
    + + +
    +
  • +
+
+
+ + +
+
+ + + +
+
+
+
+
+
+
+ or +
+ diff --git a/src/app/grouped-account-select/grouped-account-select.component.scss b/src/app/grouped-account-select/grouped-account-select.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/grouped-account-select/grouped-account-select.component.ts b/src/app/grouped-account-select/grouped-account-select.component.ts new file mode 100644 index 00000000..f40d9198 --- /dev/null +++ b/src/app/grouped-account-select/grouped-account-select.component.ts @@ -0,0 +1,330 @@ +import { Component, EventEmitter, OnInit, Output } from '@angular/core'; +import { escape } from 'lodash'; +import { finalize, take } from 'rxjs/operators'; +import { + LoginMethod, + SubAccountMetadata, + UserProfile, +} from 'src/types/identity'; +import Swal from 'sweetalert2'; +import { AccountService } from '../account.service'; +import { BackendAPIService } from '../backend-api.service'; +import { GlobalVarsService } from '../global-vars.service'; +import { isValid32BitUnsignedInt } from './account-number'; + +type AccountViewModel = SubAccountMetadata & + UserProfile & { publicKey: string } & { lastUsed?: boolean }; + +function sortAccounts(a: AccountViewModel, b: AccountViewModel) { + // sort accounts by last login timestamp DESC, + // secondarily by balance DESC + return ( + (b.lastLoginTimestamp ?? 0) - (a.lastLoginTimestamp ?? 0) || + b.balanceNanos - a.balanceNanos + ); +} + +@Component({ + selector: 'grouped-account-select', + templateUrl: './grouped-account-select.component.html', + styleUrls: ['./grouped-account-select.component.scss'], +}) +export class GroupedAccountSelectComponent implements OnInit { + @Output() onAccountSelect: EventEmitter = new EventEmitter(); + + /** + * Accounts are grouped by root public key. The root public key is the public + * key derived at account index 0 for a given seed phrase. + */ + accountGroups: Map< + string, + { + showRecoverSubAccountInput?: boolean; + accounts: AccountViewModel[]; + } + > = new Map(); + + /** + * Bound to a UI text input and used to recover a sub account. + */ + accountNumberToRecover = 0; + + /** + * UI loading state flag. + */ + loadingAccounts: boolean = false; + + get hasVisibleAccounts() { + // if any group has at least 1 visible account, return true. + return !!Array.from(this.accountGroups.values()).find( + (group) => group.accounts.length > 0 + ); + } + + constructor( + public accountService: AccountService, + public globalVars: GlobalVarsService, + private backendApi: BackendAPIService + ) {} + + ngOnInit(): void { + this.initializeAccountGroups(); + } + + initializeAccountGroups() { + this.loadingAccounts = true; + const storedUsers = Object.entries( + this.accountService.getRootLevelUsers() + ).sort( + ( + [kA, { lastLoginTimestamp: timestampA = 0 }], + [kb, { lastLoginTimestamp: timestampB = 0 }] + ) => { + // sort groups by last login timestamp DESC. We don't have balance info here. + return timestampB - timestampA; + } + ); + const accountGroupsByRootKey = new Map< + string, + { publicKey: string; accountNumber: number; lastLoginTimestamp: number }[] + >(); + + for (const [rootPublicKey, userInfo] of storedUsers) { + const accounts = !userInfo.isHidden + ? [ + { + publicKey: rootPublicKey, + accountNumber: 0, + lastLoginTimestamp: userInfo.lastLoginTimestamp ?? 0, + }, + ] + : []; + + const subAccounts = userInfo?.subAccounts ?? []; + + for (const subAccount of subAccounts) { + if (subAccount.isHidden) { + continue; + } + + const publicKeyBase58 = this.accountService.getAccountPublicKeyBase58( + rootPublicKey, + subAccount.accountNumber + ); + + accounts.push({ + publicKey: publicKeyBase58, + accountNumber: subAccount.accountNumber, + lastLoginTimestamp: subAccount.lastLoginTimestamp ?? 0, + }); + } + + accountGroupsByRootKey.set(rootPublicKey, accounts); + } + + const profileKeysToFetch = Array.from(accountGroupsByRootKey.values()) + .flat() + .map((a) => a.publicKey); + + // Fetch profiles and balances so we can show usernames in the UI (if we have them) + this.backendApi + .GetUserProfiles(profileKeysToFetch) + .pipe( + take(1), + finalize(() => (this.loadingAccounts = false)) + ) + .subscribe((users) => { + Array.from(accountGroupsByRootKey.entries()).forEach( + ([key, accounts]) => { + this.accountGroups.set(key, { + showRecoverSubAccountInput: false, + accounts: accounts + .map((account, j) => ({ + ...account, + ...users[account.publicKey], + })) + .sort(sortAccounts), + }); + + // get the first account in the list and set it as the lastUsed. + const firstKey = this.accountGroups.keys().next().value; + const firstGroup = this.accountGroups.get(firstKey); + if (firstGroup) { + firstGroup.accounts[0].lastUsed = true; + this.accountGroups.set(firstKey, firstGroup); + } + } + ); + }); + } + + /** + * We need this to address angular's weird default sorting of Maps by key when + * iterating in the template. See this issue for details. We just want to + * preserve the natural order of the Map entries: + * https://github.com/angular/angular/issues/31420 + */ + keyValueSort() { + return 1; + } + + getLoginMethodIcon(loginMethod: LoginMethod = LoginMethod.DESO): string { + return { + [LoginMethod.DESO]: 'assets/logo-deso-mark.svg', + [LoginMethod.GOOGLE]: 'assets/google_logo.svg', + [LoginMethod.METAMASK]: 'assets/metamask.png', + }[loginMethod]; + } + + selectAccount(publicKey: string) { + this.accountService.updateAccountInfo(publicKey, { + lastLoginTimestamp: Date.now(), + }); + this.onAccountSelect.emit(publicKey); + } + + hideAccount(groupKey: string, account: AccountViewModel) { + // NOTE: if there is at least 1 sub account left in the group after hiding this account, + // the user only needs the account number to recover it. If there are no sub accounts left, + // the user needs the seed phrase + the account number to recover it. + const group = this.accountGroups.get(groupKey) ?? { + accounts: [], + }; + // get a copy of the underlying array so we can preview what it looks like when hiding this account + const hiddenPreview = group.accounts + .slice() + .filter((a) => a.accountNumber !== account.accountNumber); + const hasAccountsAfterHiding = hiddenPreview.length > 0; + const { displayName, displayAccountNumber } = { + displayName: escape(this.getAccountDisplayName(account)), + displayAccountNumber: escape(account.accountNumber.toString()), + }; + Swal.fire({ + title: 'Remove Account?', + html: hasAccountsAfterHiding + ? ` +
+

+ ${displayName} +

+

You can recover this account as long as you have the account number.

+

+ +

+
+ ` + : ` +
+

+ ${displayName} +

+

+ Your account will be irrecoverable if you lose your seed phrase. +

+

Make sure you have backed up your seed phrase before continuing!

+
+ `, + showCancelButton: true, + }).then(({ isConfirmed }) => { + if (isConfirmed) { + this.accountService.updateAccountInfo(account.publicKey, { + isHidden: true, + }); + group.accounts = hiddenPreview; + this.accountGroups.set(groupKey, group); + } + }); + } + + addSubAccount( + rootPublicKey: string, + { accountNumber }: { accountNumber?: number } = {} + ) { + const addedAccountNumber = this.accountService.addSubAccount( + rootPublicKey, + { accountNumber } + ); + const publicKeyBase58 = this.accountService.getAccountPublicKeyBase58( + rootPublicKey, + addedAccountNumber + ); + // Check if this account has profile, balance, etc, and add it to the list. + // TODO: some loading state while fetching profile data? + this.backendApi + .GetUserProfiles([publicKeyBase58]) + .pipe(take(1)) + .subscribe((users) => { + const account = { + publicKey: publicKeyBase58, + accountNumber: addedAccountNumber, + ...users[publicKeyBase58], + }; + + const group = this.accountGroups.get(rootPublicKey) ?? { + accounts: [], + }; + + // if the account is already in the list, don't add it again... + if (!group.accounts.find((a) => a.accountNumber === accountNumber)) { + // Insert recovered/added account at the top of the list so + // easy to see that it was added. + group.accounts.unshift(account); + } + + this.accountGroups.set(rootPublicKey, group); + }); + } + + /** + * Shows and hides the "recover sub account" text input. + */ + toggleRecoverSubAccountForm(rootPublicKey: string) { + const group = this.accountGroups.get(rootPublicKey); + if (!group) { + return; + } + group.showRecoverSubAccountInput = !group.showRecoverSubAccountInput; + this.accountGroups.set(rootPublicKey, group); + } + + recoverSubAccount(event: SubmitEvent, rootPublicKey: string) { + event.preventDefault(); + + if (!isValid32BitUnsignedInt(this.accountNumberToRecover)) { + Swal.fire({ + title: 'Invalid Account Number', + html: `Please enter a valid account number.`, + }); + return; + } + + this.addSubAccount(rootPublicKey, { + accountNumber: this.accountNumberToRecover, + }); + } + + getAccountDisplayName(account: { username?: string; publicKey: string }) { + return account.username ?? account.publicKey; + } + + isMetaMaskAccountGroup(rootPublicKey: string) { + const rootAccount = this.accountService.getAccountInfo(rootPublicKey); + return this.accountService.isMetamaskAccount(rootAccount); + } +} diff --git a/src/styles.scss b/src/styles.scss index a1b5c85d..9fb9d8a7 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -163,6 +163,7 @@ $colors: ( // SPACING UNITS //------------------------------- $spacing-units: ( + "auto": auto, "none": 0, "xsmall": 4px, "small": 8px, @@ -753,7 +754,6 @@ a, .link--primary { @include spacing('padding-right', 'medium'); @include spacing('padding-top', 'small'); @include spacing('padding-bottom', 'small'); - @include spacing('margin-top', 'small'); @include color('text', 'lighter', 'color'); @include color('blue', 'darker', 'background-color'); @include color('blue', 'dark', 'border-color'); @@ -1106,3 +1106,11 @@ a, .link--primary { background-color: rgba(0,0,0,.8) !important; } } + +.styleless-button { + border: none; + background: none; + padding: 0; + margin: 0; + cursor: pointer; +}