diff --git a/src/app/account.service.ts b/src/app/account.service.ts index 0f95d29f..02545134 100644 --- a/src/app/account.service.ts +++ b/src/app/account.service.ts @@ -39,6 +39,29 @@ import { MetamaskService } from './metamask.service'; import { SigningService } from './signing.service'; export const ERROR_USER_NOT_FOUND = 'User not found'; + +/** + * The key used to store the sub-account reverse lookup map in local storage. + * This map is used to look up the account number for a sub-account given the + * public key. Application developers provide the "owner" public key in certain + * scenarios (generating derived keys, for example), and we need to be able to + * look up the account number for that public key in order to generate the + * private key for signing. The structure of the map is: + * + * ```json + * { + * "subAccountPublicKey": { + * "lookupKey": "rootPublicKey", + * "accountNumber": 1 + * } + * } + * ``` + * + * For historical reasons, the "lookupKey" is the root public key, which is the + * sub-account generated for account number 0. This is the "root" account, and + * is used to store the common data for all accounts in a particular account + * group, including its mnemonic and all its sub-account account numbers. + */ const SUB_ACCOUNT_REVERSE_LOOKUP_KEY = 'subAccountReverseLookup'; export interface SubAccountReversLookupEntry { @@ -62,13 +85,35 @@ export class AccountService { private signingService: SigningService, private metamaskService: MetamaskService ) { + /** + * We rebuild the sub-account reverse lookup map on every page load. This is + * to ensure there are no stale or missing entries in the map. The number of + * users in local storage is generally small, so this should not be a + * performance issue. If it does become a performance issue, we can consider + * a more sophisticated approach, but the number of users would need to be + * on the order of hundreds or thousands (very unlikely, and maybe literally + * impossible) before this would be a problem. + */ this.initializeSubAccountReverseLookup(); } // Public Getters - getPublicKeys(): any { - return Object.keys(this.getRootLevelUsers()); + getPublicKeys(): string[] { + const publicKeys: string[] = []; + const rootUsers = this.getRootLevelUsers(); + + Object.keys(rootUsers).forEach((publicKey) => { + publicKeys.push(publicKey); + const subAccounts = rootUsers[publicKey].subAccounts || []; + subAccounts.forEach((subAccount) => { + publicKeys.push( + this.getAccountPublicKeyBase58(publicKey, subAccount.accountNumber) + ); + }); + }); + + return publicKeys; } getAccountInfo(publicKey: string): PrivateUserInfo & SubAccountMetadata { @@ -96,9 +141,16 @@ export class AccountService { ); if (foundAccount) { + const keychain = this.cryptoService.getSubAccountKeychain( + rootUser.seedHex, + foundAccount.accountNumber + ); + const subAccountSeedHex = + this.cryptoService.keychainToSeedHex(keychain); info = { ...rootUser, ...foundAccount, + seedHex: subAccountSeedHex, }; } } @@ -140,12 +192,13 @@ export class AccountService { getEncryptedUsers(): { [key: string]: PublicUserInfo } { const hostname = this.globalVars.hostname; - const privateUsers = this.getRootLevelUsers(); + const rootUsers = this.getRootLevelUsers(); const publicUsers: { [key: string]: PublicUserInfo } = {}; - for (const publicKey of Object.keys(privateUsers)) { - const privateUser = privateUsers[publicKey]; - const accessLevel = this.getAccessLevel(publicKey, hostname); + for (const rootPublicKey of Object.keys(rootUsers)) { + const privateUser = rootUsers[rootPublicKey]; + + const accessLevel = this.getAccessLevel(rootPublicKey, hostname); if (accessLevel === AccessLevel.None) { continue; } @@ -166,19 +219,50 @@ export class AccountService { privateUser.seedHex ); - publicUsers[publicKey] = { + const commonFields = { hasExtraText: privateUser.extraText?.length > 0, btcDepositAddress: privateUser.btcDepositAddress, ethDepositAddress: privateUser.ethDepositAddress, version: privateUser.version, - encryptedSeedHex, network: privateUser.network, loginMethod: privateUser.loginMethod || LoginMethod.DESO, accessLevel, + }; + + publicUsers[rootPublicKey] = { + ...commonFields, + encryptedSeedHex, accessLevelHmac, derivedPublicKeyBase58Check: privateUser.derivedPublicKeyBase58Check, encryptedMessagingKeyRandomness, }; + + // To support sub-accounts for the legacy identity flow, we need to return + // a flat map of all users and their sub-accounts. Each sub-account has a + // unique seed hex that can be used for signing transactions, as well as a + // unique accessLevel hmac. + const subAccounts = privateUser.subAccounts || []; + subAccounts.forEach((subAccount) => { + const subAccountPublicKey = this.getAccountPublicKeyBase58( + rootPublicKey, + subAccount.accountNumber + ); + const accountInfo = this.getAccountInfo(subAccountPublicKey); + const subAccountEncryptedSeedHex = this.cryptoService.encryptSeedHex( + accountInfo.seedHex, + hostname + ); + const subAccountAccessLevelHmac = this.cryptoService.accessLevelHmac( + accessLevel, + accountInfo.seedHex + ); + + publicUsers[subAccountPublicKey] = { + ...commonFields, + encryptedSeedHex: subAccountEncryptedSeedHex, + accessLevelHmac: subAccountAccessLevelHmac, + }; + }); } return publicUsers; @@ -244,12 +328,7 @@ export class AccountService { .encode('array', true); // Derived keys JWT with the same expiration as the derived key. This is needed for some backend endpoints. - derivedJwt = this.signingService.signJWT( - derivedSeedHex, - 0, // NOTE: derived keys are always generated with account number 0. - true, - options - ); + derivedJwt = this.signingService.signJWT(derivedSeedHex, true, options); } else { // If the user has passed in a derived public key, use that instead. // Don't define the derived seed hex (a private key presumably already exists). @@ -261,12 +340,7 @@ export class AccountService { } // Compute the owner-signed JWT with the same expiration as the derived key. This is needed for some backend endpoints. // In case of the metamask log-in, jwt will be signed by a derived key. - jwt = this.signingService.signJWT( - account.seedHex, - account.accountNumber, - isMetamask, - options - ); + jwt = this.signingService.signJWT(account.seedHex, isMetamask, options); // Generate new btc and eth deposit addresses for the derived key. // const btcDepositAddress = this.cryptoService.keychainToBtcAddress(derivedKeychain, network); @@ -362,11 +436,9 @@ export class AccountService { ); } } else { - accessSignature = this.signingService.signHashes( - account.seedHex, - [accessHash], - account.accountNumber - )[0]; + accessSignature = this.signingService.signHashes(account.seedHex, [ + accessHash, + ])[0]; } const { messagingPublicKeyBase58Check, @@ -504,25 +576,16 @@ export class AccountService { mnemonic: string, extraText: string, network: Network, - accountNumber: number, - options: { - google?: boolean; + { + lastLoginTimestamp, + loginMethod = LoginMethod.DESO, + }: { + lastLoginTimestamp?: number; + loginMethod?: LoginMethod; } = {} ): string { - // if the account number is provided, and it is greater than 0, this is a sub account. - if (typeof accountNumber === 'number' && accountNumber > 0) { - // We've already stored the sub account in the root user's subAccounts array, - // so we can just return it's public key directly here. - const seedHex = this.cryptoService.keychainToSeedHex(keychain); - const keyPair = this.cryptoService.seedHexToKeyPair( - seedHex, - accountNumber - ); - return this.cryptoService.publicKeyToDeSoPublicKey(keyPair, network); - } - const seedHex = this.cryptoService.keychainToSeedHex(keychain); - const keyPair = this.cryptoService.seedHexToKeyPair(seedHex, 0); + const keyPair = this.cryptoService.seedHexToKeyPair(seedHex); const btcDepositAddress = this.cryptoService.keychainToBtcAddress( // @ts-ignore TODO: add "identifier" to type definition keychain.identifier, @@ -530,11 +593,6 @@ export class AccountService { ); const ethDepositAddress = this.cryptoService.publicKeyToEthAddress(keyPair); - let loginMethod: LoginMethod = LoginMethod.DESO; - if (options.google) { - loginMethod = LoginMethod.GOOGLE; - } - return this.addPrivateUser({ seedHex, mnemonic, @@ -544,12 +602,12 @@ export class AccountService { network, loginMethod, version: PrivateUserVersion.V2, - lastLoginTimestamp: Date.now(), + ...(lastLoginTimestamp && { lastLoginTimestamp }), }); } addUserWithSeedHex(seedHex: string, network: Network): string { - const keyPair = this.cryptoService.seedHexToKeyPair(seedHex, 0); + const keyPair = this.cryptoService.seedHexToKeyPair(seedHex); const helperKeychain = new HDKey(); helperKeychain.privateKey = Buffer.from(seedHex, 'hex'); // @ts-ignore TODO: add "identifier" to type definition @@ -569,7 +627,6 @@ export class AccountService { network, loginMethod: LoginMethod.DESO, version: PrivateUserVersion.V2, - lastLoginTimestamp: Date.now(), }); } @@ -643,8 +700,7 @@ export class AccountService { if (privateUser.version === PrivateUserVersion.V0) { // Add ethDepositAddress field const keyPair = this.cryptoService.seedHexToKeyPair( - privateUser.seedHex, - 0 + privateUser.seedHex ); privateUser.ethDepositAddress = this.cryptoService.publicKeyToEthAddress(keyPair); @@ -677,10 +733,7 @@ export class AccountService { publicKey: string ): string { const account = this.getAccountInfo(ownerPublicKeyBase58Check); - const privateKey = this.cryptoService.seedHexToKeyPair( - account.seedHex, - account.accountNumber - ); + const privateKey = this.cryptoService.seedHexToKeyPair(account.seedHex); const privateKeyBytes = privateKey.getPrivate().toBuffer(undefined, 32); const publicKeyBytes = this.cryptoService.publicKeyToECBuffer(publicKey); const sharedPx = ecies.derive(privateKeyBytes, publicKeyBytes); @@ -725,11 +778,9 @@ export class AccountService { let messagingKeySignature = ''; if (messagingKeyName === this.globalVars.defaultMessageKeyName) { - messagingKeySignature = this.signingService.signHashes( - account.seedHex, - [messagingKeyHash], - account.accountNumber - )[0]; + messagingKeySignature = this.signingService.signHashes(account.seedHex, [ + messagingKeyHash, + ])[0]; } return { @@ -823,18 +874,9 @@ export class AccountService { senderGroupKeyName: string, recipientPublicKey: string, message: string, - options: { - messagingKeyRandomness?: string; - ownerPublicKeyBase58Check?: string; - } = {} + messagingKeyRandomness?: string ): any { - const { accountNumber = 0 } = options.ownerPublicKeyBase58Check - ? this.getAccountInfo(options.ownerPublicKeyBase58Check) - : {}; - const privateKey = this.cryptoService.seedHexToKeyPair( - seedHex, - accountNumber - ); + const privateKey = this.cryptoService.seedHexToKeyPair(seedHex); const privateKeyBuffer = privateKey.getPrivate().toBuffer(undefined, 32); const publicKeyBuffer = @@ -846,7 +888,7 @@ export class AccountService { privateEncryptionKey = this.getMessagingKeyForSeed( seedHex, senderGroupKeyName, - options.messagingKeyRandomness + messagingKeyRandomness ); } @@ -865,16 +907,9 @@ export class AccountService { // @param encryptedHexes : string[] decryptMessagesLegacy( seedHex: string, - encryptedHexes: any, - options: { ownerPublicKeyBase58Check?: string } = {} + encryptedHexes: any ): { [key: string]: any } { - const { accountNumber = 0 } = options.ownerPublicKeyBase58Check - ? this.getAccountInfo(options.ownerPublicKeyBase58Check) - : {}; - const privateKey = this.cryptoService.seedHexToKeyPair( - seedHex, - accountNumber - ); + const privateKey = this.cryptoService.seedHexToKeyPair(seedHex); const privateKeyBuffer = privateKey.getPrivate().toBuffer(undefined, 32); const decryptedHexes: { [key: string]: any } = {}; @@ -897,21 +932,13 @@ export class AccountService { seedHex: string, encryptedMessages: EncryptedMessage[], messagingGroups: MessagingGroup[], - options: { - messagingKeyRandomness?: string; - ownerPublicKeyBase58Check?: string; - } = {} + messagingKeyRandomness?: string, + ownerPublicKeyBase58Check?: string ): Promise<{ [key: string]: any }> { - const { accountNumber = 0 } = options.ownerPublicKeyBase58Check - ? this.getAccountInfo(options.ownerPublicKeyBase58Check) - : {}; - const privateKey = this.cryptoService.seedHexToKeyPair( - seedHex, - accountNumber - ); + const privateKey = this.cryptoService.seedHexToKeyPair(seedHex); const myPublicKey = - options.ownerPublicKeyBase58Check || + ownerPublicKeyBase58Check || this.cryptoService.privateKeyToDeSoPublicKey( privateKey, this.globalVars.network @@ -1012,7 +1039,7 @@ export class AccountService { this.getMessagingKeyForSeed( seedHex, myMessagingGroupMemberEntry.GroupMemberKeyName, - options.messagingKeyRandomness + messagingKeyRandomness ); privateEncryptionKey = this.signingService .decryptGroupMessagingPrivateKeyToMember( @@ -1035,7 +1062,7 @@ export class AccountService { privateEncryptionKey = this.getMessagingKeyForSeed( seedHex, this.globalVars.defaultMessageKeyName, - options.messagingKeyRandomness + messagingKeyRandomness ); } } catch (e: any) { @@ -1062,7 +1089,7 @@ export class AccountService { addPrivateUser(userInfo: PrivateUserInfo): string { const privateUsers = this.getPrivateUsersRaw(); - const privateKey = this.cryptoService.seedHexToKeyPair(userInfo.seedHex, 0); + const privateKey = this.cryptoService.seedHexToKeyPair(userInfo.seedHex); // Metamask login will be added with the master public key. let publicKey = this.cryptoService.privateKeyToDeSoPublicKey( diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 5a11102f..2be3c923 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -73,10 +73,6 @@ export class AppComponent implements OnInit { this.globalVars.authenticatedUsers = authenticatedUsers; } - if (params.get('subAccounts') === 'true') { - this.globalVars.subAccounts = true; - } - // Callback should only be used in mobile applications, where payload is passed through URL parameters. const callback = params.get('callback') || stateParamsFromGoogle.callback; if (callback) { diff --git a/src/app/app.module.ts b/src/app/app.module.ts index e59a2157..23933e92 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -1,6 +1,7 @@ import { HttpClientModule } from '@angular/common/http'; import { NgModule } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatDialogModule } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatTooltipModule } from '@angular/material/tooltip'; import { BrowserModule } from '@angular/platform-browser'; @@ -31,6 +32,10 @@ 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 { BackupSeedDialogComponent } from './grouped-account-select/backup-seed-dialog/backup-seed-dialog.component'; +import { GroupedAccountSelectComponent } from './grouped-account-select/grouped-account-select.component'; +import { RecoverySecretComponent } from './grouped-account-select/recovery-secret/recovery-secret.component'; +import { RemoveAccountDialogComponent } from './grouped-account-select/remove-account-dialog/remove-account-dialog.component'; import { HomeComponent } from './home/home.component'; import { IconsModule } from './icons/icons.module'; import { IdentityService } from './identity.service'; @@ -98,6 +103,10 @@ import { TransactionSpendingLimitComponent } from './transaction-spending-limit/ TransactionSpendingLimitAssociationComponent, TransactionSpendingLimitAccessGroupComponent, TransactionSpendingLimitAccessGroupMemberComponent, + GroupedAccountSelectComponent, + RecoverySecretComponent, + BackupSeedDialogComponent, + RemoveAccountDialogComponent, ], imports: [ BrowserModule, @@ -114,6 +123,7 @@ import { TransactionSpendingLimitComponent } from './transaction-spending-limit/ }), BuyDeSoComponentWrapper, CookieModule.forRoot(), + MatDialogModule, ], providers: [ IdentityService, diff --git a/src/app/approve/approve.component.ts b/src/app/approve/approve.component.ts index 9001d3d8..b8ed327d 100644 --- a/src/app/approve/approve.component.ts +++ b/src/app/approve/approve.component.ts @@ -105,8 +105,7 @@ export class ApproveComponent implements OnInit { const signedTransactionHex = this.signingService.signTransaction( account.seedHex, this.transactionHex, - isDerived, - account.accountNumber + isDerived ); this.finishFlow(signedTransactionHex); } diff --git a/src/app/auth/google/google.component.ts b/src/app/auth/google/google.component.ts index 771dbd52..1d2eccdd 100644 --- a/src/app/auth/google/google.component.ts +++ b/src/app/auth/google/google.component.ts @@ -2,7 +2,7 @@ import { Component, NgZone, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { Subject } from 'rxjs'; import { environment } from '../../../environments/environment'; -import { GoogleAuthState } from '../../../types/identity'; +import { GoogleAuthState, LoginMethod } from '../../../types/identity'; import { AccountService } from '../../account.service'; import { RouteNames } from '../../app-routing.module'; import { BackendAPIService } from '../../backend-api.service'; @@ -93,9 +93,8 @@ export class GoogleComponent implements OnInit { mnemonic, extraText, network, - 0, { - google: true, + loginMethod: LoginMethod.GOOGLE, } ); } catch (err) { @@ -149,9 +148,8 @@ export class GoogleComponent implements OnInit { mnemonic, extraText, network, - 0, { - google: true, + loginMethod: LoginMethod.GOOGLE, } ); this.loading = false; diff --git a/src/app/backend-api.service.ts b/src/app/backend-api.service.ts index 56fc4827..28ebd385 100644 --- a/src/app/backend-api.service.ts +++ b/src/app/backend-api.service.ts @@ -304,11 +304,7 @@ export class BackendAPIService { } const isDerived = this.accountService.isMetamaskAccount(account); - const jwt = this.signingService.signJWT( - account.seedHex, - account.accountNumber, - isDerived - ); + const jwt = this.signingService.signJWT(account.seedHex, isDerived); return this.post(path, { ...body, ...{ JWT: jwt } }); } @@ -349,7 +345,7 @@ export class BackendAPIService { publicKeys: string[] ): Observable<{ [key: string]: UserProfile }> { const userProfiles: { [key: string]: any } = {}; - const req = this.GetUsersStateless(publicKeys, true); + const req = this.GetUsersStateless(publicKeys, true, true); if (publicKeys.length > 0) { return req .pipe( @@ -358,6 +354,7 @@ export class BackendAPIService { userProfiles[user.PublicKeyBase58Check] = { username: user.ProfileEntryResponse?.Username, profilePic: user.ProfileEntryResponse?.ProfilePic, + balanceNanos: user.BalanceNanos, }; } return userProfiles; diff --git a/src/app/crypto.service.ts b/src/app/crypto.service.ts index e1108ed2..07810bc0 100644 --- a/src/app/crypto.service.ts +++ b/src/app/crypto.service.ts @@ -142,49 +142,32 @@ export class CryptoService { nonStandard?: boolean ): HDNode { const seed = bip39.mnemonicToSeedSync(mnemonic, extraText); - return deriveKeys(seed, 0, { + return generateSubAccountKeys(seed, 0, { nonStandard, }); } getSubAccountKeychain(masterSeedHex: string, accountIndex: number): HDNode { const seedBytes = Buffer.from(masterSeedHex, 'hex'); - return deriveKeys(seedBytes, accountIndex); + return generateSubAccountKeys(seedBytes, accountIndex); } keychainToSeedHex(keychain: HDNode): string { return keychain.privateKey.toString('hex'); } - /** - * For a given parent seed hex and account number, return the corresponding private key. Public/private - * key pairs are independent and unique based on a combination of the seed hex and account number. - * @param parentSeedHex This is the seed hex used to generate multiple HD wallets/keys from a single seed. - * @param accountNumber This is the account number used to generate unique keys from the parent seed. - * @returns - */ - seedHexToKeyPair(parentSeedHex: string, accountNumber: number): EC.KeyPair { + seedHexToKeyPair(seedHex: string): EC.KeyPair { const ec = new EC('secp256k1'); - if (accountNumber === 0) { - return ec.keyFromPrivate(parentSeedHex); - } - - const hdKeys = this.getSubAccountKeychain(parentSeedHex, accountNumber); - const seedHex = this.keychainToSeedHex(hdKeys); - return ec.keyFromPrivate(seedHex); } - encryptedSeedHexToPublicKey( - encryptedSeedHex: string, - accountNumber: number - ): string { + encryptedSeedHexToPublicKey(encryptedSeedHex: string): string { const seedHex = this.decryptSeedHex( encryptedSeedHex, this.globalVars.hostname ); - const privateKey = this.seedHexToKeyPair(seedHex, accountNumber); + const privateKey = this.seedHexToKeyPair(seedHex); return this.privateKeyToDeSoPublicKey(privateKey, this.globalVars.network); } @@ -309,7 +292,7 @@ export class CryptoService { * m / purpose' / coin_type' / account' / change / address_index * See for more details: https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki#account */ -function deriveKeys( +function generateSubAccountKeys( seedBytes: Buffer, accountIndex: number, options?: { nonStandard?: boolean } diff --git a/src/app/derive/derive.component.html b/src/app/derive/derive.component.html index 44ec34cd..279a0bfa 100644 --- a/src/app/derive/derive.component.html +++ b/src/app/derive/derive.component.html @@ -21,14 +21,7 @@ }} - -
- or -
- +
0; - this.backendApi.GetAppState().subscribe((res) => { this.blockHeight = res.BlockHeight; }); @@ -69,6 +64,11 @@ export class DeriveComponent implements OnInit { throw Error('invalid query parameter permutation'); } if (params.publicKey) { + if (!this.publicKeyBase58Check) { + this.accountService.updateAccountInfo(params.publicKey, { + lastLoginTimestamp: Date.now(), + }); + } this.publicKeyBase58Check = params.publicKey; this.isSingleAccount = true; } diff --git a/src/app/global-vars.service.ts b/src/app/global-vars.service.ts index 050ff9de..e8fa4127 100644 --- a/src/app/global-vars.service.ts +++ b/src/app/global-vars.service.ts @@ -62,12 +62,6 @@ export class GlobalVarsService { */ showSkip: boolean = false; - /** - * Flag used to gate the new subAccounts functionality. After some sunset - * period (TBD), we can remove this flag and make this the default behavior. - */ - subAccounts: boolean = false; - /** * Set of public keys that have been authenticated by the calling application. * This is used as a hint to decide whether to show the derived key approval @@ -180,4 +174,36 @@ export class GlobalVarsService { formatTxCountLimit(count: number = 0): string { return count >= 1e9 ? 'UNLIMITED' : count.toLocaleString(); } + + abbreviateNumber(value: number) { + if (value === 0) { + return '0'; + } + + if (value < 0) { + return value.toString(); + } + if (value < 0.01) { + return value.toFixed(5); + } + if (value < 0.1) { + return value.toFixed(4); + } + + let shortValue; + const suffixes = ['', 'K', 'M', 'B', 'e12', 'e15', 'e18', 'e21']; + const suffixNum = Math.floor((('' + value.toFixed(0)).length - 1) / 3); + shortValue = value / Math.pow(1000, suffixNum); + if ( + Math.floor(shortValue / 100) > 0 || + shortValue / 1 === 0 || + suffixNum > 3 + ) { + return shortValue.toFixed(0) + suffixes[suffixNum]; + } + if (Math.floor(shortValue / 10) > 0 || Math.floor(shortValue) > 0) { + return shortValue.toFixed(2) + suffixes[suffixNum]; + } + return shortValue.toFixed(3) + suffixes[suffixNum]; + } } diff --git a/src/app/grouped-account-select/backup-seed-dialog/backup-seed-dialog.component.html b/src/app/grouped-account-select/backup-seed-dialog/backup-seed-dialog.component.html new file mode 100644 index 00000000..c5ddc562 --- /dev/null +++ b/src/app/grouped-account-select/backup-seed-dialog/backup-seed-dialog.component.html @@ -0,0 +1,94 @@ +
+
+ +

Backup DeSo Seed

+
+
+
+

+ Your seed phrase is the only way to recover your DeSo account. If you + lose your seed phrase, you will lose access to your DeSo account. Store + it in a safe and secure place. +

+

+ DO NOT share your seed phrase with anyone! Support agents will never + request this. +

+
+ + +
+
+ + +
+

Seed Phrase

+ +
+
+

Pass Phrase

+ +
+
+

Seed Hex

+

+ Provides an alternative means of logging in if you don't have a seed + phrase. +

+ +
+ +

+ Disabling backup makes your account more secure by preventing anyone + from revealing your seed in the future, even if they've gained access to + your device. +

+
+ +
+

+ Disabling backup means you will not be able to access your seed phrase + anymore. + Make sure that you've copied your seed phrase and stored it in a safe + place before you proceed. +

+
+ + +
+
+
+
diff --git a/src/app/grouped-account-select/backup-seed-dialog/backup-seed-dialog.component.scss b/src/app/grouped-account-select/backup-seed-dialog/backup-seed-dialog.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/grouped-account-select/backup-seed-dialog/backup-seed-dialog.component.ts b/src/app/grouped-account-select/backup-seed-dialog/backup-seed-dialog.component.ts new file mode 100644 index 00000000..9433879c --- /dev/null +++ b/src/app/grouped-account-select/backup-seed-dialog/backup-seed-dialog.component.ts @@ -0,0 +1,50 @@ +import { Component, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { AccountService } from '../../account.service'; + +@Component({ + selector: 'backup-seed-dialog', + templateUrl: './backup-seed-dialog.component.html', + styleUrls: ['./backup-seed-dialog.component.scss'], +}) +export class BackupSeedDialogComponent { + step = 1; + mnemonic?: string; + extraText?: string; + seedHex?: string; + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: { rootPublicKey: string }, + private accountService: AccountService + ) {} + + cancel(): void { + this.dialogRef.close(); + } + + showSecrets() { + if (!this.data.rootPublicKey) { + throw new Error('Root public key is required'); + } + + const { mnemonic, extraText, seedHex } = this.accountService.getAccountInfo( + this.data.rootPublicKey + ); + this.mnemonic = mnemonic; + this.extraText = extraText; + this.seedHex = seedHex; + this.step = 2; + } + + showDisableBackupConfirmation() { + this.step = 3; + } + + disableBackup() { + this.accountService.updateAccountInfo(this.data.rootPublicKey, { + exportDisabled: true, + }); + this.dialogRef.close(); + } +} 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..2bd90a92 --- /dev/null +++ b/src/app/grouped-account-select/grouped-account-select.component.html @@ -0,0 +1,196 @@ +
+ +
+
+ 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..8d79b4a4 --- /dev/null +++ b/src/app/grouped-account-select/grouped-account-select.component.ts @@ -0,0 +1,362 @@ +import { Component, EventEmitter, OnInit, Output } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { finalize, take } from 'rxjs/operators'; +import { + LoginMethod, + SubAccountMetadata, + UserProfile, +} from 'src/types/identity'; +import Swal from 'sweetalert2'; +import { isValid32BitUnsignedInt } from '../../lib/account-number'; +import { AccountService } from '../account.service'; +import { BackendAPIService } from '../backend-api.service'; +import { GlobalVarsService } from '../global-vars.service'; +import { BackupSeedDialogComponent } from './backup-seed-dialog/backup-seed-dialog.component'; +import { RemoveAccountDialogComponent } from './remove-account-dialog/remove-account-dialog.component'; + +type AccountViewModel = SubAccountMetadata & + UserProfile & { + rootPublicKey: string; + 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; + + justAddedPublicKey?: string; + + constructor( + public accountService: AccountService, + public globalVars: GlobalVarsService, + private backendApi: BackendAPIService, + public dialog: MatDialog + ) {} + + ngOnInit(): void { + this.initializeAccountGroups(); + } + + initializeAccountGroups() { + this.loadingAccounts = true; + const rootUserEntries = Object.entries( + this.accountService.getRootLevelUsers() + ); + const accountGroupsByRootKey = new Map< + string, + { + rootPublicKey: string; + publicKey: string; + accountNumber: number; + lastLoginTimestamp?: number; + }[] + >(); + + for (const [rootPublicKey, userInfo] of rootUserEntries) { + const accounts = !userInfo.isHidden + ? [ + { + rootPublicKey: rootPublicKey, + publicKey: rootPublicKey, + accountNumber: 0, + lastLoginTimestamp: userInfo.lastLoginTimestamp, + }, + ] + : []; + + const subAccounts = userInfo?.subAccounts ?? []; + + for (const subAccount of subAccounts) { + if (subAccount.isHidden) { + continue; + } + + const publicKeyBase58 = this.accountService.getAccountPublicKeyBase58( + rootPublicKey, + subAccount.accountNumber + ); + + accounts.push({ + rootPublicKey: rootPublicKey, + publicKey: publicKeyBase58, + accountNumber: subAccount.accountNumber, + lastLoginTimestamp: subAccount.lastLoginTimestamp, + }); + } + + if (accounts.length > 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) => { + const unorderedAccountGroups: typeof this.accountGroups = new Map(); + Array.from(accountGroupsByRootKey.entries()).forEach( + ([key, accounts]) => { + unorderedAccountGroups.set(key, { + accounts: accounts.map((account) => ({ + ...account, + ...users[account.publicKey], + })), + }); + } + ); + + // To sort the accounts holistically across groups, we need to flatten + // the Map values into a single array. Once they're sorted, we can determine + // which account was last used and mark it as such. There can be a case where + // no account is "last used" if the user has never logged in to any account and + // simply loaded or added accounts to the wallet. In this case, we don't mark + // any account as "last used". + const allAccounts = Array.from(unorderedAccountGroups.values()) + .map((a) => a.accounts) + .flat(); + const sortedAccounts = allAccounts.sort(sortAccounts); + const lastUsedAccount = sortedAccounts.find( + (a) => + typeof a.lastLoginTimestamp === 'number' && a.lastLoginTimestamp > 0 + ); + + if (lastUsedAccount) { + lastUsedAccount.lastUsed = true; + } + + sortedAccounts.forEach((account) => { + const group = this.accountGroups.get(account.rootPublicKey); + if (group?.accounts?.length) { + group.accounts.push(account); + } else { + this.accountGroups.set(account.rootPublicKey, { + showRecoverSubAccountInput: false, + accounts: [account], + }); + } + }); + }); + } + + /** + * 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 dialogRef = this.dialog.open(RemoveAccountDialogComponent, { + data: { + publicKey: account.publicKey, + accountNumber: account.accountNumber, + username: account.username, + isLastAccountInGroup: hiddenPreview.length === 0, + }, + }); + + dialogRef.afterClosed().subscribe((confirmed) => { + if (confirmed) { + this.accountService.updateAccountInfo(account.publicKey, { + isHidden: true, + }); + group.accounts = hiddenPreview; + this.accountGroups.set(groupKey, group); + + // if removing the last used account, select the next last used account + // in the list, if one exists. + if (account.lastUsed) { + const allAccounts = Array.from(this.accountGroups.values()) + .map((a) => a.accounts) + .flat(); + const sortedAccounts = allAccounts.sort(sortAccounts); + const lastUsedAccount = sortedAccounts.find( + (a) => + typeof a.lastLoginTimestamp === 'number' && + a.lastLoginTimestamp > 0 + ); + if (lastUsedAccount) { + lastUsedAccount.lastUsed = true; + } + } + } + }); + } + + 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. + this.backendApi + .GetUserProfiles([publicKeyBase58]) + .pipe(take(1)) + .subscribe((users) => { + const account = { + rootPublicKey: rootPublicKey, + 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)) { + group.accounts.push(account); + } + + this.accountGroups.set(rootPublicKey, group); + + // scroll to, and temporarily highlight the account that was just added/recovered + window.requestAnimationFrame(() => { + const scrollContainer = document.getElementById( + 'account-select-group-' + rootPublicKey + ); + const accountElement = document.getElementById( + 'account-select-' + publicKeyBase58 + ); + + if (scrollContainer && accountElement) { + scrollContainer.scrollTop = accountElement.offsetTop; + } + }); + + this.justAddedPublicKey = publicKeyBase58; + setTimeout(() => { + this.justAddedPublicKey = undefined; + }, 2000); + }); + } + + /** + * 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); + } + + shouldShowExportSeedButton(rootPublicKey: string) { + const rootAccount = this.accountService.getAccountInfo(rootPublicKey); + return !rootAccount.exportDisabled; + } + + exportSeed(rootPublicKey: string) { + this.dialog.open(BackupSeedDialogComponent, { + data: { rootPublicKey }, + }); + } +} diff --git a/src/app/grouped-account-select/recovery-secret/recovery-secret.component.html b/src/app/grouped-account-select/recovery-secret/recovery-secret.component.html new file mode 100644 index 00000000..708f08e1 --- /dev/null +++ b/src/app/grouped-account-select/recovery-secret/recovery-secret.component.html @@ -0,0 +1,26 @@ +
+ {{ this.isRevealed ? secret : maskedSecret }} +
+
+ + +
diff --git a/src/app/grouped-account-select/recovery-secret/recovery-secret.component.scss b/src/app/grouped-account-select/recovery-secret/recovery-secret.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/grouped-account-select/recovery-secret/recovery-secret.component.ts b/src/app/grouped-account-select/recovery-secret/recovery-secret.component.ts new file mode 100644 index 00000000..f8181fd5 --- /dev/null +++ b/src/app/grouped-account-select/recovery-secret/recovery-secret.component.ts @@ -0,0 +1,31 @@ +import { Component, Input, OnInit } from '@angular/core'; + +@Component({ + selector: 'recovery-secret', + templateUrl: './recovery-secret.component.html', + styleUrls: ['./recovery-secret.component.scss'], +}) +export class RecoverySecretComponent implements OnInit { + @Input() secret = ''; + + maskedSecret = ''; + isRevealed = false; + copySuccess = false; + + ngOnInit(): void { + this.maskedSecret = this.secret.replace(/\S/g, '*'); + } + + copySecret() { + window.navigator.clipboard.writeText(this.secret).then(() => { + this.copySuccess = true; + setTimeout(() => { + this.copySuccess = false; + }, 1500); + }); + } + + toggleRevealSecret() { + this.isRevealed = !this.isRevealed; + } +} diff --git a/src/app/grouped-account-select/remove-account-dialog/remove-account-dialog.component.html b/src/app/grouped-account-select/remove-account-dialog/remove-account-dialog.component.html new file mode 100644 index 00000000..337d4fac --- /dev/null +++ b/src/app/grouped-account-select/remove-account-dialog/remove-account-dialog.component.html @@ -0,0 +1,53 @@ +
+
+ +

Remove Account

+
+
+ +

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

+

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

+
+ +

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

+
+ Account number: {{ this.data.accountNumber }} + +
+
+
+
+ + +
+
diff --git a/src/app/grouped-account-select/remove-account-dialog/remove-account-dialog.component.scss b/src/app/grouped-account-select/remove-account-dialog/remove-account-dialog.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/grouped-account-select/remove-account-dialog/remove-account-dialog.component.ts b/src/app/grouped-account-select/remove-account-dialog/remove-account-dialog.component.ts new file mode 100644 index 00000000..18e88b58 --- /dev/null +++ b/src/app/grouped-account-select/remove-account-dialog/remove-account-dialog.component.ts @@ -0,0 +1,43 @@ +import { Component, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { AccountService } from '../../account.service'; + +@Component({ + selector: 'remove-account-dialog', + templateUrl: './remove-account-dialog.component.html', + styleUrls: ['./remove-account-dialog.component.scss'], +}) +export class RemoveAccountDialogComponent { + copySuccess = false; + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) + public data: { + publicKey: string; + accountNumber: number; + username?: string; + isLastAccountInGroup: boolean; + }, + private accountService: AccountService + ) {} + + copyAccountNumber() { + window.navigator.clipboard + .writeText(this.data.accountNumber.toString()) + .then(() => { + this.copySuccess = true; + setTimeout(() => { + this.copySuccess = false; + }, 1500); + }); + } + + cancel() { + this.dialogRef.close(false); + } + + confirm() { + this.dialogRef.close(true); + } +} diff --git a/src/app/icons/icons.module.ts b/src/app/icons/icons.module.ts index 3d3b5aa0..0dfb9723 100644 --- a/src/app/icons/icons.module.ts +++ b/src/app/icons/icons.module.ts @@ -23,6 +23,8 @@ import { CreditCard, DollarSign, ExternalLink, + Eye, + EyeOff, Feather, Flag, FolderMinus, @@ -48,6 +50,7 @@ import { RefreshCw, Repeat, RotateCw, + Save, Search, Send, Settings, @@ -117,6 +120,8 @@ const icons = { DollarSign, Diamond, ExternalLink, + Eye, + EyeOff, Feather, Flag, FolderMinus, @@ -152,6 +157,7 @@ const icons = { RefreshCw, Repeat, RotateCw, + Save, Search, Send, Settings, diff --git a/src/app/identity.service.ts b/src/app/identity.service.ts index 248fe395..1b3112cc 100644 --- a/src/app/identity.service.ts +++ b/src/app/identity.service.ts @@ -24,9 +24,9 @@ import { TransactionMetadataDeleteUserAssociation, TransactionMetadataFollow, TransactionMetadataLike, - TransactionMetadataNewMessage, TransactionMetadataNFTBid, TransactionMetadataNFTTransfer, + TransactionMetadataNewMessage, TransactionMetadataPrivateMessage, TransactionMetadataSubmitPost, TransactionMetadataSwapIdentity, @@ -222,19 +222,15 @@ export class IdentityService { const { id, - payload: { encryptedSeedHex, unsignedHashes, ownerPublicKeyBase58Check }, + payload: { encryptedSeedHex, unsignedHashes }, } = data; const seedHex = this.cryptoService.decryptSeedHex( encryptedSeedHex, this.globalVars.hostname ); - const { accountNumber = 0 } = ownerPublicKeyBase58Check - ? this.accountService.getAccountInfo(ownerPublicKeyBase58Check) - : {}; const signedHashes = this.signingService.signHashes( seedHex, - unsignedHashes, - accountNumber + unsignedHashes ); this.respond(id, { @@ -249,19 +245,15 @@ export class IdentityService { const { id, - payload: { encryptedSeedHex, unsignedHashes, ownerPublicKeyBase58Check }, + payload: { encryptedSeedHex, unsignedHashes }, } = data; const seedHex = this.cryptoService.decryptSeedHex( encryptedSeedHex, this.globalVars.hostname ); - const { accountNumber = 0 } = ownerPublicKeyBase58Check - ? this.accountService.getAccountInfo(ownerPublicKeyBase58Check) - : {}; const signatures = this.signingService.signHashesETH( seedHex, - unsignedHashes, - accountNumber + unsignedHashes ); this.respond(id, { @@ -276,7 +268,6 @@ export class IdentityService { encryptedSeedHex, transactionHex, derivedPublicKeyBase58Check, - ownerPublicKeyBase58Check, }, } = data; @@ -305,14 +296,10 @@ export class IdentityService { this.globalVars.hostname ); const isDerived = !!derivedPublicKeyBase58Check; - const { accountNumber = 0 } = ownerPublicKeyBase58Check - ? this.accountService.getAccountInfo(ownerPublicKeyBase58Check) - : {}; const signedTransactionHex = this.signingService.signTransaction( seedHex, transactionHex, - isDerived, - accountNumber + isDerived ); this.respond(id, { @@ -372,10 +359,7 @@ export class IdentityService { senderGroupKeyName, recipientPublicKey, message, - { - ownerPublicKeyBase58Check, - messagingKeyRandomness, - } + messagingKeyRandomness ); this.respond(id, { ...encryptedMessage }); @@ -421,8 +405,7 @@ export class IdentityService { try { const decryptedHexes = this.accountService.decryptMessagesLegacy( seedHex, - encryptedHexes, - data.payload.ownerPublicKeyBase58Check + encryptedHexes ); this.respond(id, { decryptedHexes, @@ -440,10 +423,8 @@ export class IdentityService { seedHex, encryptedMessages, data.payload.messagingGroups || [], - { - messagingKeyRandomness, - ownerPublicKeyBase58Check: data.payload.ownerPublicKeyBase58Check, - } + messagingKeyRandomness, + data.payload.ownerPublicKeyBase58Check ) .then( (res) => this.respond(id, { decryptedHexes: res }), @@ -462,21 +443,14 @@ export class IdentityService { const { id, - payload: { - encryptedSeedHex, - derivedPublicKeyBase58Check, - ownerPublicKeyBase58Check, - }, + payload: { encryptedSeedHex, derivedPublicKeyBase58Check }, } = data; const seedHex = this.cryptoService.decryptSeedHex( encryptedSeedHex, this.globalVars.hostname ); - const { accountNumber = 0 } = ownerPublicKeyBase58Check - ? this.accountService.getAccountInfo(ownerPublicKeyBase58Check) - : {}; const isDerived = !!derivedPublicKeyBase58Check; - const jwt = this.signingService.signJWT(seedHex, accountNumber, isDerived); + const jwt = this.signingService.signJWT(seedHex, isDerived); this.respond(id, { jwt, @@ -565,7 +539,6 @@ export class IdentityService { if (accessLevel < requiredAccessLevel) { return false; } - const seedHex = this.cryptoService.decryptSeedHex( encryptedSeedHex, this.globalVars.hostname diff --git a/src/app/log-in-options/log-in-options.component.html b/src/app/log-in-options/log-in-options.component.html index 0bd186ed..a36c34f6 100644 --- a/src/app/log-in-options/log-in-options.component.html +++ b/src/app/log-in-options/log-in-options.component.html @@ -1,4 +1,4 @@ - diff --git a/src/app/sign-up-metamask/sign-up-metamask.component.ts b/src/app/sign-up-metamask/sign-up-metamask.component.ts index f9d33cab..5939bb7d 100644 --- a/src/app/sign-up-metamask/sign-up-metamask.component.ts +++ b/src/app/sign-up-metamask/sign-up-metamask.component.ts @@ -236,8 +236,7 @@ export class SignUpMetamaskComponent implements OnInit { const signedTransactionHex = this.signingService.signTransaction( derivedKeyPair.getPrivate().toString('hex'), authorizeDerivedKeyResponse.TransactionHex, - true, - 0 + true ); this.backendApi diff --git a/src/app/sign-up/sign-up.component.html b/src/app/sign-up/sign-up.component.html index 57fef6eb..810ca6a3 100644 --- a/src/app/sign-up/sign-up.component.html +++ b/src/app/sign-up/sign-up.component.html @@ -314,7 +314,7 @@

Verify your DeSo seed phrase

Never share your DeSo seed phrase with anyone.
-
+

@@ -331,7 +331,11 @@

Verify your DeSo seed phrase

class="section--seed__container margin-bottom--medium" *ngIf="entropyService.temporaryEntropy.extraText.length > 0" > - Enter your passphrase: +

+ Enter your passphrase: +

): number { 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 + // If we get here, it means a user must have manually entered the maximum + // allowed account number into the recover account field, and subsequently + // tried to add a new account. We'll get an error if we try to use the next + // incremented value (which is too big). We cannot use a static default + // fallback value because it could conflict with an account number we already + // have stored in local storage, so we look back for the first gap in the + // numbers. 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. + // the highest available number instead of picking the lowest available number + // is that the lowest number is more likely to have been used in the past and + // we're aiming to get a fresh/unused set of keys. const maxLookBack = Math.max(sorted.length - 500, 0); let nextExpectedValueInSequence = currentHighestAccountNumber - 1; for (let i = sorted.length - 2; i >= maxLookBack; i--) { diff --git a/src/styles.scss b/src/styles.scss index 4082bdab..cd4eadb1 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -164,6 +164,7 @@ $colors: ( // SPACING UNITS //------------------------------- $spacing-units: ( + 'auto': auto, 'none': 0, 'xsmall': 4px, 'small': 8px, @@ -470,7 +471,7 @@ a, @include color('blue', 'base', 'background-color'); @include color('neutral', 'white', 'color'); &:hover { - @include color('blue', 'darker', 'background-color'); + @include color('blue', 'dark', 'background-color'); } } .button--primary--outline { @@ -480,6 +481,7 @@ a, @include color('neutral', 'transparent', 'background-color'); @include color('text', 'lighter', 'color'); &:hover { + @include color('neutral', 'white', 'color'); @include color('blue', 'darker', 'background-color'); } } @@ -617,6 +619,17 @@ a, flex-direction: row-reverse; } } + +//------------------------------- +// POSITION +//------------------------------- +.relative { + position: relative; +} +.absolute { + position: absolute; +} + //------------------------------- // LAYOUT //------------------------------- @@ -790,6 +803,7 @@ a, max-height: 245px; overflow: scroll; border: 1px solid; + scroll-behavior: smooth; @include spacing('padding', 'medium'); @include spacing('padding-top', 'small'); @include color('blue', 'dark', 'border-color'); @@ -806,7 +820,6 @@ a, @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'); @@ -817,6 +830,11 @@ a, @include color('blue', 'light', 'border-color'); cursor: pointer; } + + &--just-added { + @include color('blue', 'light', 'border-color'); + @include color('blue', 'dark', 'background-color'); + } } //------------------------------- // SECTION = Create Seed Phrase @@ -1162,3 +1180,47 @@ a, background-color: rgba(0, 0, 0, 0.8) !important; } } + +.styleless-button { + border: none; + background: none; + padding: 0; + margin: 0; + cursor: pointer; +} + +//------------------------- +// Material Dialog +//------------------------- +.cdk-overlay-dark-backdrop { + background-color: rgba(0, 0, 0, 0.8) !important; +} +.dialog { + background-color: lighten(#040609, 10%); + border: 1px solid darkgray; + border-radius: 4px; + padding: 8px; +} +.dialog__header { + position: relative; + @include color('neutral', 'white', 'color'); +} +.dialog__x { + border: none; + background: none; + position: absolute; + top: 0; + right: 0; + margin: 0; + padding: 0; + cursor: pointer; + @include color('neutral', 'white', 'color'); +} +.dialog__title { + @include font-size('large'); + @include color('neutral', 'white', 'color'); + @include font-weight('bold'); +} +.dialog__body { + padding: 12px 0; +} diff --git a/src/types/identity.ts b/src/types/identity.ts index 06f71ba0..d39cd0a5 100644 --- a/src/types/identity.ts +++ b/src/types/identity.ts @@ -28,6 +28,13 @@ export interface PrivateUserInfo extends AccountMetadata { */ subAccounts?: SubAccountMetadata[]; + /** + * Determines whether we display the "Back up your seed" button in the UI. We + * show it by default for all users, but we hide it for users who have + * explicitly disabled it. + */ + exportDisabled?: boolean; + /** DEPRECATED in favor of loginMethod */ google?: boolean; }