Merge pull request #1265 from nymtech/sprint/wallet-multiple-accounts

Support multiple accounts in the wallet
This commit is contained in:
Fouad
2022-05-13 12:46:01 +01:00
committed by GitHub
107 changed files with 3459 additions and 661 deletions
+2
View File
@@ -4,6 +4,7 @@
### Added
- wallet: added support for multiple accounts ([#1265])
- wallet: the wallet backend learned how to keep track of validator name, either hardcoded or by querying the status endpoint.
- mixnet-contract: Replace all naked `-` with `saturating_sub`.
- validator-api: add Swagger to document the REST API ([#1249]).
@@ -20,6 +21,7 @@
[#1256]: https://github.com/nymtech/nym/pull/1256
[#1257]: https://github.com/nymtech/nym/pull/1257
[#1260]: https://github.com/nymtech/nym/pull/1260
[#1265]: https://github.com/nymtech/nym/pull/1265
## [nym-wallet-v1.0.4](https://github.com/nymtech/nym/tree/nym-wallet-v1.0.4) (2022-05-04)
+3
View File
@@ -42,7 +42,9 @@
"react-hook-form": "^7.14.2",
"react-router-dom": "^5.2.0",
"semver": "^6.3.0",
"string-to-color": "^2.2.2",
"use-clipboard-copy": "^0.2.0",
"uuid": "^8.3.2",
"yup": "^0.32.9"
},
"devDependencies": {
@@ -71,6 +73,7 @@
"@types/react-router": "^5.1.18",
"@types/react-router-dom": "^5.1.8",
"@types/semver": "^7.3.8",
"@types/uuid": "^8.3.4",
"@typescript-eslint/eslint-plugin": "^5.13.0",
"@typescript-eslint/parser": "^5.13.0",
"babel-loader": "^8.2.2",
+14 -4
View File
@@ -83,12 +83,22 @@ pub enum BackendError {
WalletFileAlreadyExists,
#[error("The wallet file is not found")]
WalletFileNotFound,
#[error("Account ID not found in wallet")]
NoSuchIdInWallet,
#[error("Account ID already found in wallet")]
IdAlreadyExistsInWallet,
#[error("Login ID not found in wallet")]
WalletNoSuchLoginId,
#[error("Account ID not found in wallet login")]
WalletNoSuchAccountIdInWalletLogin,
#[error("Login ID already found in wallet")]
WalletLoginIdAlreadyExists,
#[error("Account ID already found in wallet login")]
WalletAccountIdAlreadyExistsInWalletLogin,
#[error("Adding a different password to the wallet not currently supported")]
WalletDifferentPasswordDetected,
#[error("Unexpted mnemonic account for login")]
WalletUnexpectedMnemonicAccount,
#[error("Unexpted multiple account entry for login")]
WalletUnexpectedMultipleAccounts,
#[error("Failed to derive address from mnemonic")]
FailedToDeriveAddress,
}
impl Serialize for BackendError {
+6
View File
@@ -35,14 +35,20 @@ fn main() {
tauri::Builder::default()
.manage(Arc::new(RwLock::new(State::default())))
.invoke_handler(tauri::generate_handler![
mixnet::account::add_account_for_password,
mixnet::account::connect_with_mnemonic,
mixnet::account::create_new_account,
mixnet::account::create_new_mnemonic,
mixnet::account::create_password,
mixnet::account::does_password_file_exist,
mixnet::account::get_balance,
mixnet::account::list_accounts,
mixnet::account::sign_in_decrypted_account,
mixnet::account::logout,
mixnet::account::remove_account_for_password,
mixnet::account::remove_password,
mixnet::account::show_mnemonic_for_account_in_password,
mixnet::account::sign_in_decrypted_account,
mixnet::account::sign_in_with_password,
mixnet::account::switch_network,
mixnet::account::validate_mnemonic,
@@ -5,7 +5,7 @@ use crate::network::Network as WalletNetwork;
use crate::network_config;
use crate::nymd_client;
use crate::state::State;
use crate::wallet_storage::{self, DEFAULT_WALLET_ACCOUNT_ID};
use crate::wallet_storage::{self, DEFAULT_LOGIN_ID};
use bip39::{Language, Mnemonic};
use config::defaults::all::Network;
@@ -21,6 +21,7 @@ use std::sync::Arc;
use strum::IntoEnumIterator;
use tokio::sync::RwLock;
use url::Url;
use validator_client::nymd::wallet::{AccountData, DirectSecp256k1HdWallet};
use validator_client::{nymd::SigningNymdClient, Client};
@@ -51,6 +52,14 @@ pub struct CreatedAccount {
mnemonic: String,
}
#[cfg_attr(test, derive(ts_rs::TS))]
#[cfg_attr(test, ts(export, export_to = "../src/types/rust/createdaccount.ts"))]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AccountEntry {
id: String,
address: String,
}
#[cfg_attr(test, derive(ts_rs::TS))]
#[cfg_attr(test, ts(export, export_to = "../src/types/rust/balance.ts"))]
#[derive(Serialize, Deserialize)]
@@ -107,9 +116,8 @@ pub async fn create_new_account(
}
#[tauri::command]
pub fn create_new_mnemonic() -> Result<String, BackendError> {
let rand_mnemonic = random_mnemonic();
Ok(rand_mnemonic.to_string())
pub fn create_new_mnemonic() -> String {
random_mnemonic().to_string()
}
#[tauri::command]
@@ -353,18 +361,18 @@ pub fn does_password_file_exist() -> Result<bool, BackendError> {
}
#[tauri::command]
pub fn create_password(mnemonic: String, password: String) -> Result<(), BackendError> {
pub fn create_password(mnemonic: &str, password: String) -> Result<(), BackendError> {
if does_password_file_exist()? {
return Err(BackendError::WalletFileAlreadyExists);
}
log::info!("Creating password");
let mnemonic = Mnemonic::from_str(&mnemonic)?;
let mnemonic = Mnemonic::from_str(mnemonic)?;
let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap();
// Currently we only support a single, default, id in the wallet
let id = wallet_storage::WalletAccountId::new(DEFAULT_WALLET_ACCOUNT_ID.to_string());
// Currently we only support a single, default, login id in the wallet
let login_id = wallet_storage::LoginId::new(DEFAULT_LOGIN_ID.to_string());
let password = wallet_storage::UserPassword::new(password);
wallet_storage::store_wallet_login_information(mnemonic, hd_path, id, &password)
wallet_storage::store_login_with_multiple_accounts(mnemonic, hd_path, login_id, &password)
}
#[tauri::command]
@@ -375,15 +383,261 @@ pub async fn sign_in_with_password(
log::info!("Signing in with password");
// Currently we only support a single, default, id in the wallet
let id = wallet_storage::WalletAccountId::new(DEFAULT_WALLET_ACCOUNT_ID.to_string());
let login_id = wallet_storage::LoginId::new(DEFAULT_LOGIN_ID.to_string());
let password = wallet_storage::UserPassword::new(password);
let stored_account = wallet_storage::load_existing_wallet_login_information(&id, &password)?;
_connect_with_mnemonic(stored_account.mnemonic().clone(), state).await
let stored_login = wallet_storage::load_existing_login(&login_id, &password)?;
let mnemonic = extract_first_mnemonic(&stored_login)?;
let first_login_id_when_converting = login_id.into();
set_state_with_all_accounts(stored_login, first_login_id_when_converting, state.clone()).await?;
_connect_with_mnemonic(mnemonic, state).await
}
fn extract_first_mnemonic(
stored_account: &wallet_storage::StoredLogin,
) -> Result<Mnemonic, BackendError> {
let mnemonic = match stored_account {
wallet_storage::StoredLogin::Mnemonic(ref account) => account.mnemonic().clone(),
wallet_storage::StoredLogin::Multiple(ref accounts) => {
// Login using the first account in the list
accounts
.get_accounts()
.next()
.ok_or(BackendError::WalletNoSuchAccountIdInWalletLogin)?
.mnemonic()
.clone()
}
};
Ok(mnemonic)
}
#[tauri::command]
pub fn remove_password() -> Result<(), BackendError> {
log::info!("Removing password");
let id = wallet_storage::WalletAccountId::new(DEFAULT_WALLET_ACCOUNT_ID.to_string());
wallet_storage::remove_wallet_login_information(&id)
let login_id = wallet_storage::LoginId::new(DEFAULT_LOGIN_ID.to_string());
wallet_storage::remove_login(&login_id)
}
#[tauri::command]
pub async fn add_account_for_password(
mnemonic: &str,
password: &str,
account_id: &str,
state: tauri::State<'_, Arc<RwLock<State>>>,
) -> Result<AccountEntry, BackendError> {
log::info!("Adding account for the current password: {account_id}");
let mnemonic = Mnemonic::from_str(mnemonic)?;
let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap();
// Currently we only support a single, default, login id in the wallet
let login_id = wallet_storage::LoginId::new(DEFAULT_LOGIN_ID.to_string());
let account_id = wallet_storage::AccountId::new(account_id.to_string());
let password = wallet_storage::UserPassword::new(password.to_string());
wallet_storage::append_account_to_login(
mnemonic.clone(),
hd_path,
login_id.clone(),
account_id.clone(),
&password,
)?;
let address = {
let state = state.read().await;
let network: Network = state.current_network().into();
derive_address(mnemonic, network.bech32_prefix())?.to_string()
};
// Re-read all the acccounts from the wallet to reset the state, rather than updating it
// incrementally
let stored_login = wallet_storage::load_existing_login(&login_id, &password)?;
// NOTE: since we are appending, this id shouldn't be needed, but setting the state is supposed
// to be a general function
let first_id_when_converting = login_id.into();
set_state_with_all_accounts(stored_login, first_id_when_converting, state).await?;
Ok(AccountEntry {
id: account_id.to_string(),
address,
})
}
// The first `AccoundId` when converting is the `LoginId` for the entry that was loaded.
async fn set_state_with_all_accounts(
stored_login: wallet_storage::StoredLogin,
first_id_when_converting: wallet_storage::AccountId,
state: tauri::State<'_, Arc<RwLock<State>>>,
) -> Result<(), BackendError> {
log::trace!("Set state with accounts:");
let all_accounts: Vec<_> = stored_login
.unwrap_into_multiple_accounts(first_id_when_converting)
.into_accounts()
.collect();
for account in &all_accounts {
log::trace!("account: {:?}", account);
}
let mut w_state = state.write().await;
w_state.set_all_accounts(all_accounts);
Ok(())
}
#[tauri::command]
pub async fn remove_account_for_password(
password: &str,
account_id: &str,
state: tauri::State<'_, Arc<RwLock<State>>>,
) -> Result<(), BackendError> {
log::info!("Removing account: {account_id}");
// Currently we only support a single, default, id in the wallet
let login_id = wallet_storage::LoginId::new(DEFAULT_LOGIN_ID.to_string());
let account_id = wallet_storage::AccountId::new(account_id.to_string());
let password = wallet_storage::UserPassword::new(password.to_string());
wallet_storage::remove_account_from_login(&login_id, &account_id, &password)?;
// Load to reset the internal state
let stored_login = wallet_storage::load_existing_login(&login_id, &password)?;
// NOTE: Since we removed from a multi-account login, this id shouldn't be needed, but setting
// the state is supposed to be a general function
let first_account_id_when_converting = login_id.into();
set_state_with_all_accounts(stored_login, first_account_id_when_converting, state).await
}
fn derive_address(
mnemonic: bip39::Mnemonic,
prefix: &str,
) -> Result<cosmrs::AccountId, BackendError> {
DirectSecp256k1HdWallet::from_mnemonic(prefix, mnemonic)?
.try_derive_accounts()?
.first()
.map(AccountData::address)
.cloned()
.ok_or(BackendError::FailedToDeriveAddress)
}
#[tauri::command]
pub async fn list_accounts(
state: tauri::State<'_, Arc<RwLock<State>>>,
) -> Result<Vec<AccountEntry>, BackendError> {
log::trace!("Listing accounts");
let state = state.read().await;
let network: Network = state.current_network().into();
let prefix = network.bech32_prefix();
let all_accounts = state
.get_all_accounts()
.map(|account| AccountEntry {
id: account.id().to_string(),
address: derive_address(account.mnemonic().clone(), prefix)
.unwrap()
.to_string(),
})
.map(|account| {
log::trace!("{:?}", account);
account
})
.collect();
Ok(all_accounts)
}
#[tauri::command]
pub fn show_mnemonic_for_account_in_password(
account_id: String,
password: String,
) -> Result<String, BackendError> {
log::info!("Getting mnemonic for: {account_id}");
let login_id = wallet_storage::LoginId::new(DEFAULT_LOGIN_ID.to_string());
let account_id = wallet_storage::AccountId::new(account_id);
let password = wallet_storage::UserPassword::new(password);
let stored_account = wallet_storage::load_existing_login(&login_id, &password)?;
let mnemonic = match stored_account {
wallet_storage::StoredLogin::Mnemonic(ref account) => account.mnemonic().clone(),
wallet_storage::StoredLogin::Multiple(ref accounts) => {
for account in accounts.get_accounts() {
log::debug!("{:?}", account);
}
accounts
.get_account(&account_id)
.ok_or(BackendError::WalletNoSuchAccountIdInWalletLogin)?
.mnemonic()
.clone()
}
};
Ok(mnemonic.to_string())
}
#[tauri::command]
pub async fn sign_in_decrypted_account(
account_id: &str,
state: tauri::State<'_, Arc<RwLock<State>>>,
) -> Result<Account, BackendError> {
log::info!("Signing in to already decrypted account: {account_id}");
let mnemonic = {
let state = state.read().await;
let account = &state
.get_all_accounts()
.find(|a| a.id().as_ref() == account_id)
.ok_or(BackendError::WalletNoSuchAccountIdInWalletLogin)?;
account.mnemonic().clone()
};
{
state.write().await.logout();
}
_connect_with_mnemonic(mnemonic, state).await
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use crate::wallet_storage::{
self,
account_data::{MnemonicAccount, WalletAccount},
};
// This decryptes a stored wallet file using the same procedure as when signing in. Most tests
// related to the encryped wallet storage is in `wallet_storage`.
#[test]
fn decrypt_stored_wallet_for_sign_in() {
const SAVED_WALLET: &str = "src/wallet_storage/test-data/saved-wallet.json";
let wallet_file = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(SAVED_WALLET);
let login_id = wallet_storage::LoginId::new("first".to_string());
let account_id = wallet_storage::AccountId::new("first".to_string());
let password = wallet_storage::UserPassword::new("password".to_string());
let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap();
let stored_login =
wallet_storage::load_existing_login_at_file(&wallet_file, &login_id, &password).unwrap();
let mnemonic = extract_first_mnemonic(&stored_login).unwrap();
let expected_mnemonic = bip39::Mnemonic::from_str("country mean universe text phone begin deputy reject result good cram illness common cluster proud swamp digital patrol spread bar face december base kick").unwrap();
assert_eq!(mnemonic, expected_mnemonic);
let all_accounts: Vec<_> = stored_login
.unwrap_into_multiple_accounts(account_id.clone())
.into_accounts()
.collect();
assert_eq!(
all_accounts,
vec![WalletAccount::new(
account_id,
MnemonicAccount::new(expected_mnemonic, hd_path),
)]
);
}
#[test]
fn decrypt_stored_wallet_multiple_for_sign_in() {
// WIP(JON): same as above but with file containing multiple accounts
}
}
+13
View File
@@ -1,5 +1,6 @@
use crate::error::BackendError;
use crate::network::Network;
use crate::wallet_storage::account_data::WalletAccount;
use crate::{config, network_config};
use strum::IntoEnumIterator;
@@ -46,6 +47,10 @@ pub struct State {
signing_clients: HashMap<Network, Client<SigningNymdClient>>,
current_network: Network,
// All the accounts the we get from decrypting the wallet. We hold on to these for being able to
// switch accounts on-the-fly
all_accounts: Vec<WalletAccount>,
/// Validators that have been fetched dynamically, probably during startup.
fetched_validators: config::OptionalValidators,
@@ -112,6 +117,14 @@ impl State {
self.current_network
}
pub(crate) fn set_all_accounts(&mut self, all_accounts: Vec<WalletAccount>) {
self.all_accounts = all_accounts
}
pub(crate) fn get_all_accounts(&self) -> impl Iterator<Item = &WalletAccount> {
self.all_accounts.iter()
}
pub fn logout(&mut self) {
self.signing_clients = HashMap::new();
}
@@ -1,6 +1,19 @@
// Copyright 2022 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: Apache-2.0
// The wallet storage is a single json file, containing multiple entries. These are referred to as
// Logins, and has a plaintext id tag attached.
//
// Each encrypted login contains either a single account, or a list of multiple accounts.
//
// NOTE: A not insignificant amount of complexity comes from being able to handle both these cases,
// instead of, for example, converting a single account to a list of multiple accounts with a single
// entry. This also avoids resaving the wallet file when opening a file created with an earlier
// version of the wallet.
//
// In the future we might want to simplify by dropping the support for a single account entry,
// instead treating as muliple accounts with one entry.
use cosmrs::bip32::DerivationPath;
use serde::{Deserialize, Serialize};
use zeroize::Zeroize;
@@ -8,15 +21,16 @@ use zeroize::Zeroize;
use crate::error::BackendError;
use super::encryption::EncryptedData;
use super::password::WalletAccountId;
use super::password::{AccountId, LoginId};
use super::UserPassword;
const CURRENT_WALLET_FILE_VERSION: u32 = 1;
/// The wallet, stored as a serialized json file.
#[derive(Serialize, Deserialize, Debug)]
pub(crate) struct StoredWallet {
version: u32,
accounts: Vec<EncryptedAccount>,
accounts: Vec<EncryptedLogin>,
}
impl StoredWallet {
@@ -25,16 +39,52 @@ impl StoredWallet {
self.version
}
pub fn is_empty(&self) -> bool {
self.accounts.is_empty()
}
#[allow(unused)]
pub fn len(&self) -> usize {
self.accounts.len()
}
pub fn remove_account(&mut self, id: &WalletAccountId) -> Option<EncryptedAccount> {
pub fn is_empty(&self) -> bool {
self.accounts.is_empty()
}
pub fn add_encrypted_login(&mut self, new_login: EncryptedLogin) -> Result<(), BackendError> {
if self.get_encrypted_login(&new_login.id).is_ok() {
return Err(BackendError::WalletLoginIdAlreadyExists);
}
self.accounts.push(new_login);
Ok(())
}
fn get_encrypted_login(&self, id: &LoginId) -> Result<&EncryptedData<StoredLogin>, BackendError> {
self
.accounts
.iter()
.find(|account| &account.id == id)
.map(|account| &account.account)
.ok_or(BackendError::WalletNoSuchLoginId)
}
fn get_encrypted_login_mut(&mut self, id: &LoginId) -> Result<&mut EncryptedLogin, BackendError> {
self
.accounts
.iter_mut()
.find(|account| &account.id == id)
.ok_or(BackendError::WalletNoSuchLoginId)
}
#[cfg(test)]
pub fn get_encrypted_login_by_index(&self, index: usize) -> Option<&EncryptedLogin> {
self.accounts.get(index)
}
pub fn replace_encrypted_login(&mut self, new_login: EncryptedLogin) -> Result<(), BackendError> {
let login = self.get_encrypted_login_mut(&new_login.id)?;
*login = new_login;
Ok(())
}
pub fn remove_encrypted_login(&mut self, id: &LoginId) -> Option<EncryptedLogin> {
if let Some(index) = self.accounts.iter().position(|account| &account.id == id) {
log::info!("Removing from wallet file: {id}");
Some(self.accounts.remove(index))
@@ -44,43 +94,15 @@ impl StoredWallet {
}
}
#[allow(unused)]
pub fn encrypted_account_by_index(&self, index: usize) -> Option<&EncryptedAccount> {
self.accounts.get(index)
}
fn encrypted_account(
pub fn decrypt_login(
&self,
id: &WalletAccountId,
) -> Result<&EncryptedData<StoredAccount>, BackendError> {
self
.accounts
.iter()
.find(|account| &account.id == id)
.map(|account| &account.account)
.ok_or(BackendError::NoSuchIdInWallet)
}
pub fn add_encrypted_account(
&mut self,
new_account: EncryptedAccount,
) -> Result<(), BackendError> {
if self.encrypted_account(&new_account.id).is_ok() {
return Err(BackendError::IdAlreadyExistsInWallet);
}
self.accounts.push(new_account);
Ok(())
}
pub fn decrypt_account(
&self,
id: &WalletAccountId,
id: &LoginId,
password: &UserPassword,
) -> Result<StoredAccount, BackendError> {
self.encrypted_account(id)?.decrypt_struct(password)
) -> Result<StoredLogin, BackendError> {
self.get_encrypted_login(id)?.decrypt_struct(password)
}
pub fn decrypt_all(&self, password: &UserPassword) -> Result<Vec<StoredAccount>, BackendError> {
pub fn decrypt_all(&self, password: &UserPassword) -> Result<Vec<StoredLogin>, BackendError> {
self
.accounts
.iter()
@@ -102,39 +124,176 @@ impl Default for StoredWallet {
}
}
/// Each entry in the stored wallet file. An id field in plaintext and an encrypted stored login.
#[derive(Serialize, Deserialize, Debug)]
pub(crate) struct EncryptedAccount {
pub id: WalletAccountId,
pub account: EncryptedData<StoredAccount>,
pub(crate) struct EncryptedLogin {
pub id: LoginId,
pub account: EncryptedData<StoredLogin>,
}
// future-proofing
impl EncryptedLogin {
pub(crate) fn encrypt(
id: LoginId,
login: &StoredLogin,
password: &UserPassword,
) -> Result<Self, BackendError> {
Ok(EncryptedLogin {
id,
account: super::encryption::encrypt_struct(login, password)?,
})
}
}
/// A stored login is either a account, such as a mnemonic, or a list of multiple accounts where
/// each has an inner id. Future proofed for having private key backed accounts.
#[derive(Serialize, Deserialize, Debug, Zeroize)]
#[serde(untagged)]
#[zeroize(drop)]
pub(crate) enum StoredAccount {
pub(crate) enum StoredLogin {
Mnemonic(MnemonicAccount),
// PrivateKey(PrivateKeyAccount)
Multiple(MultipleAccounts),
}
impl StoredAccount {
pub(crate) fn new_mnemonic_backed_account(
mnemonic: bip39::Mnemonic,
hd_path: DerivationPath,
) -> StoredAccount {
StoredAccount::Mnemonic(MnemonicAccount { mnemonic, hd_path })
impl StoredLogin {
#[cfg(test)]
pub(crate) fn as_mnemonic_account(&self) -> Option<&MnemonicAccount> {
match self {
StoredLogin::Mnemonic(mn) => Some(mn),
StoredLogin::Multiple(_) => None,
}
}
// If we add accounts backed by something that is not a mnemonic, this should probably be changed
// to return `Option<..>`.
pub(crate) fn mnemonic(&self) -> &bip39::Mnemonic {
#[cfg(test)]
pub(crate) fn as_multiple_accounts(&self) -> Option<&MultipleAccounts> {
match self {
StoredAccount::Mnemonic(account) => account.mnemonic(),
StoredLogin::Mnemonic(_) => None,
StoredLogin::Multiple(accounts) => Some(accounts),
}
}
// Return the login as multiple accounts, and if there is only a single mnemonic backed account,
// return a set containing only the single account paired with the account id passed as function
// argument.
pub(crate) fn unwrap_into_multiple_accounts(self, id: AccountId) -> MultipleAccounts {
match self {
StoredLogin::Mnemonic(ref account) => vec![WalletAccount::new(id, account.clone())].into(),
StoredLogin::Multiple(ref accounts) => accounts.clone(),
}
}
}
#[derive(Serialize, Deserialize, Debug)]
/// Multiple stored accounts, each entry having an id and a data field.
#[derive(Serialize, Deserialize, Clone, Debug, Zeroize, PartialEq, Eq)]
pub(crate) struct MultipleAccounts {
accounts: Vec<WalletAccount>,
}
impl MultipleAccounts {
pub(crate) fn new() -> Self {
MultipleAccounts {
accounts: Vec::new(),
}
}
pub(crate) fn get_accounts(&self) -> impl Iterator<Item = &WalletAccount> {
self.accounts.iter()
}
pub(crate) fn get_account(&self, id: &AccountId) -> Option<&WalletAccount> {
self.accounts.iter().find(|account| &account.id == id)
}
pub(crate) fn into_accounts(self) -> impl Iterator<Item = WalletAccount> {
self.accounts.into_iter()
}
#[allow(unused)]
pub(crate) fn len(&self) -> usize {
self.accounts.len()
}
pub(crate) fn is_empty(&self) -> bool {
self.accounts.is_empty()
}
pub(crate) fn add(
&mut self,
id: AccountId,
mnemonic: bip39::Mnemonic,
hd_path: DerivationPath,
) -> Result<(), BackendError> {
if self.get_account(&id).is_some() {
Err(BackendError::WalletAccountIdAlreadyExistsInWalletLogin)
} else {
self.accounts.push(WalletAccount::new(
id,
MnemonicAccount::new(mnemonic, hd_path),
));
Ok(())
}
}
pub(crate) fn remove(&mut self, id: &AccountId) -> Result<(), BackendError> {
if self.get_account(id).is_none() {
return Err(BackendError::WalletNoSuchAccountIdInWalletLogin);
}
self.accounts.retain(|accounts| &accounts.id != id);
Ok(())
}
}
impl From<Vec<WalletAccount>> for MultipleAccounts {
fn from(accounts: Vec<WalletAccount>) -> MultipleAccounts {
Self { accounts }
}
}
/// An entry in the list of stored accounts
#[derive(Serialize, Deserialize, Clone, Debug, Zeroize, PartialEq, Eq)]
pub(crate) struct WalletAccount {
id: AccountId,
account: AccountData,
}
impl WalletAccount {
pub(crate) fn new(id: AccountId, mnemonic_account: MnemonicAccount) -> Self {
Self {
id,
account: AccountData::Mnemonic(mnemonic_account),
}
}
pub(crate) fn id(&self) -> &AccountId {
&self.id
}
pub(crate) fn mnemonic(&self) -> &bip39::Mnemonic {
match self.account {
AccountData::Mnemonic(ref account) => account.mnemonic(),
}
}
#[cfg(test)]
pub(crate) fn hd_path(&self) -> &DerivationPath {
match self.account {
AccountData::Mnemonic(ref account) => account.hd_path(),
}
}
}
/// An account usually is a mnemonic account, but in the future it might be backed by a private
/// key.
#[derive(Serialize, Deserialize, Clone, Debug, Zeroize, PartialEq, Eq)]
#[serde(untagged)]
#[zeroize(drop)]
enum AccountData {
Mnemonic(MnemonicAccount),
// PrivateKey(PrivateKeyAccount)
}
/// An account backed by a unique mnemonic.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub(crate) struct MnemonicAccount {
mnemonic: bip39::Mnemonic,
#[serde(with = "display_hd_path")]
@@ -142,11 +301,15 @@ pub(crate) struct MnemonicAccount {
}
impl MnemonicAccount {
pub(crate) fn new(mnemonic: bip39::Mnemonic, hd_path: DerivationPath) -> Self {
Self { mnemonic, hd_path }
}
pub(crate) fn mnemonic(&self) -> &bip39::Mnemonic {
&self.mnemonic
}
#[allow(unused)]
#[cfg(test)]
pub(crate) fn hd_path(&self) -> &DerivationPath {
&self.hd_path
}
File diff suppressed because it is too large Load Diff
@@ -6,22 +6,75 @@ use std::fmt;
use serde::{Deserialize, Serialize};
use zeroize::Zeroize;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct WalletAccountId(String);
// The `LoginId` is the top level id in the wallet file, and is not stored encrypted
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Zeroize)]
pub(crate) struct LoginId(String);
impl WalletAccountId {
pub(crate) fn new(id: String) -> WalletAccountId {
WalletAccountId(id)
impl LoginId {
pub(crate) fn new(id: String) -> LoginId {
LoginId(id)
}
}
impl AsRef<str> for WalletAccountId {
impl AsRef<str> for LoginId {
fn as_ref(&self) -> &str {
self.0.as_ref()
}
}
impl fmt::Display for WalletAccountId {
impl From<String> for LoginId {
fn from(id: String) -> Self {
Self::new(id)
}
}
impl From<&str> for LoginId {
fn from(id: &str) -> Self {
Self::new(id.to_string())
}
}
impl fmt::Display for LoginId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
// For each encrypted login, we can have multiple encrypted accounts.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Zeroize)]
pub(crate) struct AccountId(String);
impl AccountId {
pub(crate) fn new(id: String) -> AccountId {
AccountId(id)
}
}
impl AsRef<str> for AccountId {
fn as_ref(&self) -> &str {
self.0.as_ref()
}
}
impl From<String> for AccountId {
fn from(id: String) -> Self {
Self::new(id)
}
}
impl From<&str> for AccountId {
fn from(id: &str) -> Self {
Self::new(id.to_string())
}
}
impl From<LoginId> for AccountId {
fn from(login_id: LoginId) -> Self {
Self::new(login_id.0)
}
}
impl fmt::Display for AccountId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
@@ -0,0 +1,8 @@
import React from 'react';
import { Avatar } from '@mui/material';
import stc from 'string-to-color';
import { TAccount } from 'src/types';
export const AccountAvatar = ({ name }: Pick<TAccount, 'name'>) => (
<Avatar sx={{ bgcolor: stc(name), width: 35, height: 35 }}>{name?.split('')[0]}</Avatar>
);
@@ -0,0 +1,68 @@
import React, { useContext } from 'react';
import { Box, ListItem, ListItemAvatar, ListItemButton, ListItemText, Tooltip, Typography } from '@mui/material';
import { useClipboard } from 'use-clipboard-copy';
import { AccountsContext } from 'src/context';
import { AccountAvatar } from './AccountAvatar';
export const AccountItem = ({ name, address }: { name: string; address: string }) => {
const { selectedAccount, handleSelectAccount, setDialogToDisplay, setAccountMnemonic } = useContext(AccountsContext);
const { copy, copied } = useClipboard({ copiedTimeout: 1000 });
return (
<ListItem
disablePadding
disableGutters
sx={selectedAccount?.id === name ? { bgcolor: 'rgba(33, 208, 115, 0.1)' } : {}}
>
<ListItemButton disableRipple onClick={() => handleSelectAccount(name)}>
<ListItemAvatar sx={{ minWidth: 0, mr: 2 }}>
<AccountAvatar name={name} />
</ListItemAvatar>
<ListItemText
primary={name}
secondary={
<Box>
<Tooltip title={copied ? 'Copied!' : `Click to copy address ${address}`}>
<Typography
component="span"
variant="body2"
onClick={(e: React.MouseEvent<HTMLElement>) => {
e.stopPropagation();
copy(address);
}}
sx={{ '&:hover': { color: 'grey.900' } }}
>
{address}
</Typography>
</Tooltip>
<Box sx={{ mt: 0.5 }}>
<Typography
variant="body2"
component="span"
sx={{ textDecoration: 'underline', mb: 0.5, '&:hover': { color: 'primary.main' } }}
onClick={(e: React.MouseEvent<HTMLElement>) => {
e.stopPropagation();
setDialogToDisplay('Mnemonic');
setAccountMnemonic((accountMnemonic) => ({ ...accountMnemonic, accountName: name }));
}}
>
Show mnemonic
</Typography>
</Box>
</Box>
}
/>
{/* edit and remove accounts todo */}
{/* <ListItemIcon>
<IconButton
onClick={(e) => {
e.stopPropagation();
handleAccountToEdit(name);
}}
>
<Edit />
</IconButton>
</ListItemIcon> */}
</ListItemButton>
</ListItem>
);
};
@@ -0,0 +1,15 @@
import React from 'react';
import { Button } from '@mui/material';
import { AccountEntry } from 'src/types';
import { AccountAvatar } from './AccountAvatar';
export const AccountOverview = ({ account, onClick }: { account: AccountEntry; onClick: () => void }) => (
<Button
startIcon={<AccountAvatar name={account.id} />}
sx={{ color: 'nym.text.dark' }}
onClick={onClick}
disableRipple
>
{account.id}
</Button>
);
@@ -0,0 +1,36 @@
import React, { useContext, useState } from 'react';
import { AccountsContext, AppContext } from 'src/context';
import { EditAccountModal } from './EditAccountModal';
import { AddAccountModal } from './AddAccountModal';
import { AccountsModal } from './AccountsModal';
import { MnemonicModal } from './MnemonicModal';
import { AccountOverview } from './AccountOverview';
import { MultiAccountHowTo } from './MultiAccountHowTo';
export const Accounts = () => {
const { accounts, selectedAccount, setDialogToDisplay } = useContext(AccountsContext);
return accounts && selectedAccount ? (
<>
<AccountOverview account={selectedAccount} onClick={() => setDialogToDisplay('Accounts')} />
<AccountsModal />
<AddAccountModal />
<EditAccountModal />
<MnemonicModal />
</>
) : null;
};
export const SingleAccount = () => {
const [showHowToDialog, setShowHowToDialog] = useState(false);
const { clientDetails } = useContext(AppContext);
return (
<>
<AccountOverview
account={{ id: 'Account 1', address: clientDetails?.client_address || '' }}
onClick={() => setShowHowToDialog(true)}
/>
<MultiAccountHowTo show={showHowToDialog} handleClose={() => setShowHowToDialog(false)} />
</>
);
};
@@ -0,0 +1,48 @@
import React, { useContext } from 'react';
import { Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Typography } from '@mui/material';
import { Add, ArrowDownwardSharp, Close } from '@mui/icons-material';
import { AccountsContext } from 'src/context';
import { AccountItem } from './AccountItem';
export const AccountsModal = ({ onClose }: { onClose?: () => void }) => {
const { accounts, dialogToDisplay, setDialogToDisplay } = useContext(AccountsContext);
const handleClose = () => {
setDialogToDisplay(undefined);
onClose?.();
};
return (
<Dialog open={dialogToDisplay === 'Accounts'} onClose={handleClose} fullWidth hideBackdrop>
<DialogTitle>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Typography variant="h6">Accounts</Typography>
<IconButton onClick={handleClose}>
<Close />
</IconButton>
</Box>
<Typography variant="body1" sx={{ color: 'grey.600' }}>
Switch between accounts
</Typography>
</DialogTitle>
<DialogContent sx={{ padding: 0 }}>
{accounts?.map(({ id, address }) => (
<AccountItem name={id} address={address} key={address} />
))}
</DialogContent>
<DialogActions sx={{ p: 3 }}>
<Button startIcon={<ArrowDownwardSharp />} onClick={() => setDialogToDisplay('Import')}>
Import account
</Button>
<Button
disableElevation
variant="contained"
startIcon={<Add fontSize="small" />}
onClick={() => setDialogToDisplay('Add')}
>
Add new account
</Button>
</DialogActions>
</Dialog>
);
};
@@ -0,0 +1,282 @@
import React, { useContext, useEffect, useState } from 'react';
import {
Box,
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
Stack,
TextField,
Typography,
} from '@mui/material';
import { ArrowBackSharp } from '@mui/icons-material';
import { useClipboard } from 'use-clipboard-copy';
import { createMnemonic, validateMnemonic } from 'src/requests';
import { AccountsContext } from 'src/context';
import { PasswordInput } from 'src/components';
import { Mnemonic } from '../Mnemonic';
import { Console } from 'src/utils/console';
import { match } from 'assert';
const createAccountSteps = [
'Copy and save mnemonic for your new account',
'Name your new account',
'Confirm the password used to login to your wallet',
];
const importAccountSteps = [
'Provide mnemonic of account you want to import',
'Name your new account',
'Confirm the password used to login to your wallet',
];
const MnemonicStep = ({ mnemonic, onNext }: { mnemonic: string; onNext: () => void }) => {
const { copy, copied } = useClipboard({ copiedTimeout: 5000 });
return (
<Box sx={{ mt: 1 }}>
<DialogContent>
<Mnemonic mnemonic={mnemonic} handleCopy={copy} copied={copied} />
</DialogContent>
<DialogActions sx={{ p: 3, pt: 0 }}>
<Button disabled={!copied} fullWidth disableElevation variant="contained" size="large" onClick={onNext}>
I saved my mnemonic
</Button>
</DialogActions>
</Box>
);
};
const ImportMnemonic = ({
value,
onChange,
onNext,
}: {
value: string;
onChange: (value: string) => void;
onNext: () => void;
}) => {
const [error, setError] = useState<string>();
const handleOnNext = async () => {
const isValid = await validateMnemonic(value);
if (!isValid) setError('Please enter a valid mnemonic. Mnemonic must have a word count that is a multiple of 6.');
else onNext();
};
return (
<Box sx={{ mt: 1 }}>
<DialogContent>
<Stack spacing={2} alignItems="center">
{error && (
<Typography variant="body1" sx={{ color: 'error.main', mb: 2 }}>
{error}
</Typography>
)}
<TextField
multiline
placeholder="Mnemonic to import"
rows={3}
value={value}
onChange={(e) => {
onChange(e.target.value);
setError(undefined);
}}
fullWidth
type="password"
/>
</Stack>
</DialogContent>
<DialogActions sx={{ p: 3, pt: 0 }}>
<Button
disabled={value.length === 0}
fullWidth
disableElevation
variant="contained"
size="large"
onClick={handleOnNext}
>
Next
</Button>
</DialogActions>
</Box>
);
};
const NameAccount = ({ onNext }: { onNext: (value: string) => void }) => {
const [value, setValue] = useState('');
const [error, setError] = useState<string>();
const nameValidation = /^([a-zA-Z0-9\s]){1,20}$/;
const handleNext = (value: string) => {
if (!nameValidation.test(value))
[setError('Account name must contain only letters and numbers and be between 1 and 20 characters')];
else onNext(value);
};
return (
<Box sx={{ mt: 1 }}>
<DialogContent>
<Typography variant="body1" sx={{ color: 'error.main', mb: 2 }}>
{error}
</Typography>
<TextField
placeholder="Account name"
value={value}
onChange={(e) => {
setValue(e.target.value);
setError(undefined);
}}
fullWidth
/>
</DialogContent>
<DialogActions sx={{ p: 3, pt: 0 }}>
<Button
disabled={!value.length}
fullWidth
disableElevation
variant="contained"
size="large"
onClick={() => handleNext(value)}
>
Next
</Button>
</DialogActions>
</Box>
);
};
const ConfirmPassword = ({ onConfirm }: { onConfirm: (password: string) => void }) => {
const [value, setValue] = useState('');
const { isLoading, error } = useContext(AccountsContext);
return (
<Box sx={{ mt: 1 }}>
<DialogContent>
{error && (
<Typography variant="body1" sx={{ color: 'error.main', mb: 2 }}>
{error}
</Typography>
)}
<PasswordInput
password={value}
onUpdatePassword={(pswrd) => setValue(pswrd)}
label="Confirm password"
autoFocus
/>
</DialogContent>
<DialogActions sx={{ p: 3, pt: 0 }}>
<Button
disabled={!value.length || isLoading}
fullWidth
disableElevation
variant="contained"
size="large"
onClick={() => onConfirm(value)}
endIcon={isLoading && <CircularProgress size={20} />}
>
Add account
</Button>
</DialogActions>
</Box>
);
};
export const AddAccountModal = () => {
const [step, setStep] = useState(0);
const [data, setData] = useState({
mnemonic: '',
accountName: '',
});
const { dialogToDisplay, setDialogToDisplay, handleAddAccount, setError } = useContext(AccountsContext);
const generateMnemonic = async () => {
const mnemon = await createMnemonic();
setData((d) => ({ ...d, mnemonic: mnemon }));
};
const handleClose = () => {
setDialogToDisplay('Accounts');
resetState();
};
const resetState = () => {
setData({ mnemonic: '', accountName: '' });
setStep(0);
setError(undefined);
};
useEffect(() => {
if (dialogToDisplay === 'Add') generateMnemonic();
if (dialogToDisplay === 'Accounts') resetState();
}, [dialogToDisplay]);
useEffect(() => {
setError(undefined);
}, [step]);
return (
<Dialog
open={dialogToDisplay === 'Add' || dialogToDisplay === 'Import'}
onClose={handleClose}
fullWidth
hideBackdrop
>
<DialogTitle sx={{ pb: 0 }}>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Typography variant="h6">{`${dialogToDisplay} new account`}</Typography>
<IconButton onClick={() => (step === 0 ? handleClose() : setStep((s) => s - 1))}>
<ArrowBackSharp />
</IconButton>
</Box>
<Typography sx={{ mt: 2 }}>
{dialogToDisplay === 'Add' ? createAccountSteps[step] : importAccountSteps[step]}
</Typography>
</DialogTitle>
{(() => {
switch (step) {
case 0:
return dialogToDisplay === 'Add' ? (
<MnemonicStep mnemonic={data.mnemonic} onNext={() => setStep((s) => s + 1)} />
) : (
<ImportMnemonic
value={data.mnemonic}
onChange={(value) => setData((d) => ({ ...d, mnemonic: value }))}
onNext={() => setStep((s) => s + 1)}
/>
);
case 1:
return (
<NameAccount
onNext={(accountName) => {
setData((d) => ({ ...d, accountName }));
setStep((s) => s + 1);
}}
/>
);
case 2:
return (
<ConfirmPassword
onConfirm={async (password) => {
if (data.accountName && data.mnemonic) {
try {
await handleAddAccount({ accountName: data.accountName, mnemonic: data.mnemonic, password });
setStep(0);
setDialogToDisplay('Accounts');
} catch (e) {
Console.error(e as string);
}
}
}}
/>
);
default:
return null;
}
})()}
</Dialog>
);
};
@@ -0,0 +1,68 @@
import React, { useContext, useEffect, useState } from 'react';
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
TextField,
Typography,
} from '@mui/material';
import { Close } from '@mui/icons-material';
import { AccountsContext } from 'src/context';
export const EditAccountModal = () => {
const [accountName, setAccountName] = useState('');
const { accountToEdit, dialogToDisplay, setDialogToDisplay, handleEditAccount } = useContext(AccountsContext);
useEffect(() => {
setAccountName(accountToEdit ? accountToEdit?.id : '');
}, [accountToEdit]);
return (
<Dialog open={dialogToDisplay === 'Edit'} onClose={() => setDialogToDisplay('Accounts')} fullWidth hideBackdrop>
<DialogTitle>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Typography variant="h6">Edit account name</Typography>
<IconButton onClick={() => setDialogToDisplay('Accounts')}>
<Close />
</IconButton>
</Box>
<Typography variant="body1" sx={{ color: 'grey.600' }}>
New wallet address
</Typography>
</DialogTitle>
<DialogContent sx={{ p: 0 }}>
<Box sx={{ px: 3, mt: 1 }}>
<TextField
label="Account name"
fullWidth
value={accountName}
onChange={(e) => setAccountName(e.target.value)}
autoFocus
/>
</Box>
</DialogContent>
<DialogActions sx={{ p: 3 }}>
<Button
fullWidth
disableElevation
variant="contained"
size="large"
onClick={() => {
if (accountToEdit) {
handleEditAccount({ ...accountToEdit, id: accountName });
setDialogToDisplay('Accounts');
}
}}
disabled={!accountName?.length}
>
Edit
</Button>
</DialogActions>
</Dialog>
);
};
@@ -0,0 +1,66 @@
import React, { useContext, useState } from 'react';
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
TextField,
Typography,
} from '@mui/material';
import { Close } from '@mui/icons-material';
import { AccountsContext } from 'src/context';
export const ImportAccountModal = () => {
const [mnemonic, setMnemonic] = useState('');
const { dialogToDisplay, setDialogToDisplay, handleImportAccount } = useContext(AccountsContext);
const handleClose = () => {
setMnemonic('');
setDialogToDisplay('Accounts');
};
return (
<Dialog open={dialogToDisplay === 'Import'} onClose={handleClose} fullWidth hideBackdrop>
<DialogTitle>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Typography variant="h6">Import account</Typography>
<IconButton onClick={handleClose}>
<Close />
</IconButton>
</Box>
<Typography variant="body1" sx={{ color: 'grey.600' }}>
Provide mnemonic of account you want to import
</Typography>
</DialogTitle>
<DialogContent sx={{ p: 0 }}>
<Box sx={{ px: 3, mt: 1 }}>
<TextField
placeholder="Paste or type your mnemonic here"
fullWidth
value={mnemonic}
onChange={(e) => setMnemonic(e.target.value)}
autoFocus
multiline
rows={3}
/>
</Box>
</DialogContent>
<DialogActions sx={{ p: 3 }}>
<Button
fullWidth
disableElevation
variant="contained"
size="large"
onClick={() => handleImportAccount({ id: '', address: '' })}
disabled={!mnemonic.length}
>
Import account
</Button>
</DialogActions>
</Dialog>
);
};
@@ -0,0 +1,100 @@
import React, { useContext, useState } from 'react';
import {
Box,
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
Typography,
} from '@mui/material';
import { ArrowBackSharp } from '@mui/icons-material';
import { AccountsContext } from 'src/context';
import { useClipboard } from 'use-clipboard-copy';
import { Mnemonic } from '../Mnemonic';
import { PasswordInput } from '../textfields';
export const MnemonicModal = () => {
const [password, setPassword] = useState('');
const { copy, copied } = useClipboard({ copiedTimeout: 5000 });
const {
dialogToDisplay,
setDialogToDisplay,
accountMnemonic,
setAccountMnemonic,
handleGetAcccountMnemonic,
error,
setError,
isLoading,
} = useContext(AccountsContext);
const handleClose = () => {
setAccountMnemonic({ value: undefined, accountName: undefined });
setError(undefined);
setDialogToDisplay('Accounts');
setPassword('');
};
return (
<Dialog open={dialogToDisplay === 'Mnemonic'} onClose={handleClose} fullWidth hideBackdrop>
<DialogTitle>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Typography variant="h6">Display mnemonic</Typography>
<IconButton onClick={handleClose}>
<ArrowBackSharp />
</IconButton>
</Box>
<Typography variant="body1" sx={{ color: 'grey.600' }}>
{`Display mnemonic for: ${accountMnemonic?.accountName}`}
</Typography>
</DialogTitle>
<DialogContent sx={{ p: 0 }}>
<Box sx={{ px: 3, mt: 1 }}>
{error && (
<Typography variant="body1" sx={{ color: 'error.main', mb: 2 }}>
{error}
</Typography>
)}
{!accountMnemonic.value ? (
<>
<Typography sx={{ mb: 2 }}>Enter the password used to login to your wallet</Typography>
<PasswordInput
label="Password"
password={password}
onUpdatePassword={(pswrd) => setPassword(pswrd)}
autoFocus
/>
</>
) : (
<Mnemonic mnemonic={accountMnemonic.value} handleCopy={copy} copied={copied} />
)}
</Box>
</DialogContent>
<DialogActions sx={{ p: 3 }}>
{!accountMnemonic.value && (
<Button
disableRipple
disabled={!password.length || isLoading}
fullWidth
disableElevation
variant="contained"
size="large"
onClick={async () => {
if (accountMnemonic?.accountName) {
setError(undefined);
await handleGetAcccountMnemonic({ password, accountName: accountMnemonic?.accountName });
}
}}
endIcon={isLoading && <CircularProgress size={20} />}
>
Display mnemonic
</Button>
)}
</DialogActions>
</Dialog>
);
};
@@ -0,0 +1,39 @@
import React from 'react';
import { Alert, Box, Dialog, DialogContent, DialogTitle, IconButton, Stack, Typography } from '@mui/material';
import { Close } from '@mui/icons-material';
const passwordCreationSteps = [
'Log out',
'When signing in, select “Sign in with mnemonic”',
'On the next screen click “Create a password for your account”',
'Sign in to wallet with your new password',
'Now you can create multiple accounts',
];
export const MultiAccountHowTo = ({ show, handleClose }: { show: boolean; handleClose: () => void }) => (
<Dialog open={show} onClose={handleClose} fullWidth hideBackdrop>
<DialogTitle>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Typography variant="h6">Multi accounts</Typography>
<IconButton onClick={handleClose}>
<Close />
</IconButton>
</Box>
<Typography variant="body1" sx={{ color: 'grey.600' }}>
How to set up multiple accounts
</Typography>
</DialogTitle>
<DialogContent>
<Stack spacing={2}>
<Alert severity="warning" icon={false}>
<Typography>In order to create multiple accounts your wallet needs a password.</Typography>
<Typography>Follow steps below to create password.</Typography>
</Alert>
<Typography>How to create a password for your account</Typography>
{passwordCreationSteps.map((step, index) => (
<Typography key={step}>{`${index + 1}. ${step}`}</Typography>
))}
</Stack>
</DialogContent>
</Dialog>
);
@@ -0,0 +1,16 @@
import React, { useContext } from 'react';
import { AccountsProvider, AppContext } from 'src/context';
import { Accounts, SingleAccount } from './Accounts';
export const MultiAccounts = () => {
const { loginType } = useContext(AppContext);
if (loginType === 'password') {
return (
<AccountsProvider>
<Accounts />
</AccountsProvider>
);
}
return <SingleAccount />;
};
@@ -0,0 +1,10 @@
export const accounts = [
{
id: 'Account 1',
address: 'n107wsxkj08hycflnkp5ayfg6rt3pt0psm7w2t9r',
},
{
id: 'Account 2',
address: 'n1dgp04lqaasnzaww66zwdp6u24smqe7ltuny8vk',
},
];
@@ -0,0 +1,18 @@
import React from 'react';
import { Box } from '@mui/material';
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { MockAccountsProvider } from 'src/context/mocks/accounts';
import { Accounts } from '../Accounts';
export default {
title: 'Wallet / Multi Account',
component: Accounts,
} as ComponentMeta<typeof Accounts>;
export const Default: ComponentStory<typeof Accounts> = () => (
<Box display="flex" alignContent="center">
<MockAccountsProvider>
<Accounts />
</MockAccountsProvider>
</Box>
);
@@ -0,0 +1 @@
export type TDialog = 'Accounts' | 'Add' | 'Edit' | 'Import';
+21 -7
View File
@@ -1,21 +1,28 @@
import React, { useContext, useEffect, useState } from 'react';
import React, { useContext } from 'react';
import { AppBar as MuiAppBar, Grid, IconButton, Toolbar } from '@mui/material';
import { useHistory } from 'react-router-dom';
import { Logout } from '@mui/icons-material';
import TerminalIcon from '@mui/icons-material/Terminal';
import { ClientContext } from '../context/main';
import { AppContext } from '../context/main';
import { NetworkSelector } from './NetworkSelector';
import { Node as NodeIcon } from '../svg-icons/node';
import { MultiAccounts } from './Accounts';
import { config } from '../../config';
export const AppBar = () => {
const { showSettings, logOut, handleShowSettings, handleShowTerminal, appEnv } = useContext(ClientContext);
const { showSettings, logOut, handleShowSettings, handleShowTerminal, appEnv } = useContext(AppContext);
const history = useHistory();
return (
<MuiAppBar position="sticky" sx={{ boxShadow: 'none', bgcolor: 'transparent' }}>
<Toolbar disableGutters>
<Grid container justifyContent="space-between" alignItems="center" flexWrap="nowrap">
<Grid item>
<NetworkSelector />
<Grid item container alignItems="center" spacing={1}>
<Grid item>
<MultiAccounts />
</Grid>
<Grid item>
<NetworkSelector />
</Grid>
</Grid>
<Grid item container justifyContent="flex-end" md={12} lg={5} spacing={2}>
{(appEnv?.SHOW_TERMINAL || config.IS_DEV_MODE) && (
@@ -35,7 +42,14 @@ export const AppBar = () => {
</IconButton>
</Grid>
<Grid item>
<IconButton size="small" onClick={logOut} sx={{ color: 'nym.background.dark' }}>
<IconButton
size="small"
onClick={async () => {
await logOut();
history.push('/');
}}
sx={{ color: 'nym.background.dark' }}
>
<Logout fontSize="small" />
</IconButton>
</Grid>
@@ -1,6 +1,6 @@
import * as React from 'react';
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { Box, Typography } from '@mui/material';
import { Box } from '@mui/material';
import { ClientAddressDisplay } from './ClientAddress';
export default {
+2 -2
View File
@@ -1,6 +1,6 @@
import React, { FC, useContext } from 'react';
import { Box, Typography, Tooltip } from '@mui/material';
import { ClientContext } from '../context/main';
import { AppContext } from '../context/main';
import { CopyToClipboard } from './CopyToClipboard';
import { splice } from '../utils';
@@ -53,6 +53,6 @@ export const ClientAddressDisplay: FC<ClientAddressProps & { address?: string }>
);
export const ClientAddress: FC<ClientAddressProps> = ({ ...props }) => {
const { clientDetails } = useContext(ClientContext);
const { clientDetails } = useContext(AppContext);
return <ClientAddressDisplay {...props} address={clientDetails?.client_address} />;
};
+5 -14
View File
@@ -1,17 +1,8 @@
import React from 'react';
import { FallbackProps } from 'react-error-boundary';
import { Alert, AlertTitle, Button } from '@mui/material';
import { Alert } from '@mui/material';
export const ErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => (
<div>
<Alert severity="error" data-testid="error-message">
<AlertTitle>{error.name}</AlertTitle>
{error.message}
</Alert>
<Alert severity="error" data-testid="stack-trace">
<AlertTitle>Stack trace</AlertTitle>
{error.stack}
</Alert>
<Button onClick={resetErrorBoundary}>Back to safety</Button>
</div>
export const Error = ({ message }: { message: string }) => (
<Alert severity="error" variant="outlined" data-testid="error" sx={{ color: 'error.light', width: '100%' }}>
{message}
</Alert>
);
@@ -0,0 +1,17 @@
import React from 'react';
import { FallbackProps } from 'react-error-boundary';
import { Alert, AlertTitle, Button } from '@mui/material';
export const ErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => (
<div>
<Alert severity="error" data-testid="error-message">
<AlertTitle>{error.name}</AlertTitle>
{error.message}
</Alert>
<Alert severity="error" data-testid="stack-trace">
<AlertTitle>Stack trace</AlertTitle>
{error.stack}
</Alert>
<Button onClick={resetErrorBoundary}>Back to safety</Button>
</div>
);
+2 -2
View File
@@ -2,11 +2,11 @@ import React, { useState, useEffect, useContext } from 'react';
import { Typography } from '@mui/material';
import { Operation } from '../types';
import { getGasFee } from '../requests';
import { ClientContext } from '../context/main';
import { AppContext } from '../context/main';
export const Fee = ({ feeType }: { feeType: Operation }) => {
const [fee, setFee] = useState<string>();
const { currency } = useContext(ClientContext);
const { currency } = useContext(AppContext);
const getFee = async () => {
const res = await getGasFee(feeType);
+38
View File
@@ -0,0 +1,38 @@
import React from 'react';
import { Box, LinearProgress, Stack } from '@mui/material';
import { NymWordmark } from '@nymproject/react';
import { AuthTheme } from 'src/theme';
export const LoadingPage = () => (
<AuthTheme>
<Box
sx={{
position: 'fixed',
height: '100vh',
width: '100vw',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
overflow: 'auto',
bgcolor: 'nym.background.dark',
zIndex: 2000,
}}
>
<Box
sx={{
width: '100%',
display: 'flex',
justifyContent: 'center',
margin: 'auto',
}}
>
<Stack spacing={3} alignItems="center" sx={{ width: 1080 }}>
<NymWordmark width={75} fill="white" />
<Box width="25%">
<LinearProgress variant="indeterminate" color="primary" />
</Box>
</Stack>
</Box>
</Box>
</AuthTheme>
);
+37
View File
@@ -0,0 +1,37 @@
import React from 'react';
import { Alert, Button, Stack, TextField, Typography } from '@mui/material';
import { Check, ContentCopySharp } from '@mui/icons-material';
export const Mnemonic = ({
mnemonic,
copied,
handleCopy,
}: {
mnemonic: string;
copied: boolean;
handleCopy: (text?: string) => void;
}) => (
<Stack spacing={2} alignItems="center">
<Alert severity="warning" icon={false} sx={{ display: 'block' }}>
<Typography sx={{ textAlign: 'center' }}>
Below is your 24 word mnemonic, make sure to store it in a safe place for accessing your wallet in the future
</Typography>
</Alert>
<TextField multiline rows={3} value={mnemonic} fullWidth />
<Button
color="inherit"
disableElevation
size="large"
onClick={() => {
handleCopy(mnemonic);
}}
sx={{
width: 250,
}}
endIcon={!copied ? <ContentCopySharp /> : <Check color="success" />}
>
Copy mnemonic
</Button>
</Stack>
);
+2 -2
View File
@@ -2,7 +2,7 @@ import React, { useContext, useEffect } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { List, ListItem, ListItemIcon, ListItemText } from '@mui/material';
import { AccountBalanceWalletOutlined, ArrowBack, ArrowForward, Description, Settings } from '@mui/icons-material';
import { ClientContext } from '../context/main';
import { AppContext } from '../context/main';
import { Bond, Delegate, Unbond, Undelegate } from '../svg-icons';
const routesSchema = [
@@ -44,7 +44,7 @@ const routesSchema = [
];
export const Nav = () => {
const { isAdminAddress, handleShowAdmin } = useContext(ClientContext);
const { isAdminAddress, handleShowAdmin } = useContext(AppContext);
const location = useLocation();
useEffect(() => {
@@ -1,7 +1,7 @@
import React, { useState, useContext } from 'react';
import { Button, List, ListItem, ListItemIcon, ListItemText, ListSubheader, Popover } from '@mui/material';
import { ArrowDropDown, CheckSharp } from '@mui/icons-material';
import { ClientContext } from '../context/main';
import { AppContext } from '../context/main';
import { config } from '../../config';
import { Network } from '../types';
@@ -23,7 +23,7 @@ const NetworkItem: React.FC<{ title: string; isSelected: boolean; onSelect: () =
);
export const NetworkSelector = () => {
const { network, switchNetwork } = useContext(ClientContext);
const { network, switchNetwork } = useContext(AppContext);
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
+4 -3
View File
@@ -14,10 +14,11 @@ export const NymCard: React.FC<{
title: string | React.ReactElement;
subheader?: string;
Action?: React.ReactNode;
Icon?: any;
Icon?: React.ReactNode;
noPadding?: boolean;
}> = ({ title, subheader, Action, Icon, noPadding, children }) => (
<Card variant="outlined" sx={{ overflow: 'auto' }}>
borderless?: boolean;
}> = ({ title, subheader, Action, Icon, noPadding, borderless, children }) => (
<Card variant="outlined" sx={{ overflow: 'auto', ...(borderless && { border: 'none', dropShadow: 'none' }) }}>
<CardHeader
sx={{ p: 3, color: 'nym.background.dark' }}
title={<Title title={title} Icon={Icon} />}
+2 -2
View File
@@ -1,9 +1,9 @@
import React from 'react';
import { Box, Typography } from '@mui/material';
export const Title: React.FC<{ title: string | React.ReactNode; Icon: any }> = ({ title, Icon }) => (
export const Title: React.FC<{ title: string | React.ReactNode; Icon?: React.ReactNode }> = ({ title, Icon }) => (
<Box display="flex" alignItems="center">
{Icon && <Icon sx={{ mr: 1 }} />}{' '}
{Icon}
<Typography variant="h6" sx={{ fontWeight: 600 }}>
{title}
</Typography>
@@ -1,6 +1,6 @@
import React, { useContext, useEffect, useState } from 'react';
import { FormControl, InputLabel, ListItemText, MenuItem, Select, SelectChangeEvent, Typography } from '@mui/material';
import { ClientContext } from '../context/main';
import { AppContext } from '../context/main';
type TPoolOption = 'balance' | 'locked';
@@ -12,7 +12,7 @@ export const TokenPoolSelector: React.FC<{ disabled: boolean; onSelect: (pool: T
const {
userBalance: { tokenAllocation, balance, fetchBalance, fetchTokenAllocation },
currency,
} = useContext(ClientContext);
} = useContext(AppContext);
useEffect(() => {
(async () => {
+3 -1
View File
@@ -1,4 +1,4 @@
export * from './Error';
export * from './ErrorFallback';
export * from './CopyToClipboard';
export * from './NymCard';
export * from './Nav';
@@ -15,3 +15,5 @@ export * from './ClientAddress';
export * from './InfoToolTip';
export * from './Title';
export * from './TokenPoolSelector';
export * from './LoadingPage';
export * from './textfields';
@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { Box, IconButton, Stack, TextField } from '@mui/material';
import { Visibility, VisibilityOff } from '@mui/icons-material';
import { Error } from './error';
import { Error } from './Error';
export const MnemonicInput: React.FC<{
mnemonic: string;
+135
View File
@@ -0,0 +1,135 @@
import React, { createContext, Dispatch, SetStateAction, useContext, useEffect, useMemo, useState } from 'react';
import { AccountEntry } from 'src/types';
import { addAccount as addAccountRequest, showMnemonicForAccount } from 'src/requests';
import { useSnackbar } from 'notistack';
import { AppContext } from './main';
type TAccounts = {
accounts?: AccountEntry[];
selectedAccount?: AccountEntry;
accountToEdit?: AccountEntry;
dialogToDisplay?: TAccountsDialog;
isLoading: boolean;
error?: string;
accountMnemonic: TAccountMnemonic;
setError: Dispatch<SetStateAction<string | undefined>>;
setAccountMnemonic: Dispatch<SetStateAction<TAccountMnemonic>>;
handleAddAccount: (data: { accountName: string; mnemonic: string; password: string }) => void;
setDialogToDisplay: (dialog?: TAccountsDialog) => void;
handleSelectAccount: (accountId: string) => void;
handleAccountToEdit: (accountId: string) => void;
handleEditAccount: (account: AccountEntry) => void;
handleImportAccount: (account: AccountEntry) => void;
handleGetAcccountMnemonic: (data: { password: string; accountName: string }) => void;
};
export type TAccountsDialog = 'Accounts' | 'Add' | 'Edit' | 'Import' | 'Mnemonic';
export type TAccountMnemonic = { value?: string; accountName?: string };
export const AccountsContext = createContext({} as TAccounts);
export const AccountsProvider: React.FC = ({ children }) => {
const [accounts, setAccounts] = useState<AccountEntry[]>([]);
const [selectedAccount, setSelectedAccount] = useState<AccountEntry>();
const [accountToEdit, setAccountToEdit] = useState<AccountEntry>();
const [dialogToDisplay, setDialogToDisplay] = useState<TAccountsDialog>();
const [accountMnemonic, setAccountMnemonic] = useState<TAccountMnemonic>({
value: undefined,
accountName: undefined,
});
const [error, setError] = useState<string>();
const [isLoading, setIsLoading] = useState(false);
const { onAccountChange, storedAccounts } = useContext(AppContext);
const { enqueueSnackbar } = useSnackbar();
const handleAddAccount = async ({
accountName,
mnemonic,
password,
}: {
accountName: string;
mnemonic: string;
password: string;
}) => {
setIsLoading(true);
try {
const newAccount = await addAccountRequest({
accountName,
mnemonic,
password,
});
setAccounts((accs) => [...accs, newAccount]);
enqueueSnackbar('New account created', { variant: 'success' });
} catch (e) {
setError(`Error adding account: ${e}`);
throw new Error();
} finally {
setIsLoading(false);
}
};
const handleEditAccount = (account: AccountEntry) =>
setAccounts((accs) => accs?.map((acc) => (acc.address === account.address ? account : acc)));
const handleImportAccount = (account: AccountEntry) => setAccounts((accs) => [...(accs ? [...accs] : []), account]);
const handleAccountToEdit = (accountName: string) =>
setAccountToEdit(accounts?.find((acc) => acc.id === accountName));
const handleSelectAccount = async (accountName: string) => {
if (accountName !== selectedAccount?.id) {
await onAccountChange(accountName);
const match = accounts?.find((acc) => acc.id === accountName);
setSelectedAccount(match);
}
};
const handleGetAcccountMnemonic = async ({ password, accountName }: { password: string; accountName: string }) => {
try {
setIsLoading(true);
const mnemonic = await showMnemonicForAccount({ password, accountName });
setAccountMnemonic({ value: mnemonic, accountName });
} catch (e) {
setError(e as string);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (storedAccounts) {
setAccounts(storedAccounts);
}
if (storedAccounts && !selectedAccount) {
setSelectedAccount(storedAccounts[0]);
}
}, [storedAccounts]);
return (
<AccountsContext.Provider
value={useMemo(
() => ({
error,
setError,
accounts,
selectedAccount,
accountToEdit,
dialogToDisplay,
accountMnemonic,
setDialogToDisplay,
setAccountMnemonic,
isLoading,
handleAddAccount,
handleEditAccount,
handleAccountToEdit,
handleSelectAccount,
handleImportAccount,
handleGetAcccountMnemonic,
}),
[accounts, selectedAccount, accountToEdit, dialogToDisplay, isLoading, error, accountMnemonic],
)}
>
{children}
</AccountsContext.Provider>
);
};
@@ -1,11 +1,10 @@
import React, { createContext, useEffect, useMemo, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { createMnemonic } from 'src/requests';
import { TMnemonicWords } from '../types';
import { TMnemonicWords } from 'src/pages/auth/types';
export const SignInContext = createContext({} as TSignInContent);
export const AuthContext = createContext({} as TAuthContext);
export type TSignInContent = {
export type TAuthContext = {
error?: string;
password: string;
mnemonic: string;
@@ -22,23 +21,17 @@ const mnemonicToArray = (mnemonic: string): TMnemonicWords =>
.split(' ')
.reduce((a, c: string, index) => [...a, { name: c, index: index + 1, disabled: false }], [] as TMnemonicWords);
export const SignInProvider: React.FC = ({ children }) => {
export const AuthProvider: React.FC = ({ children }) => {
const [password, setPassword] = useState('');
const [mnemonic, setMnemonic] = useState('');
const [mnemonicWords, setMnemonicWords] = useState<TMnemonicWords>([]);
const [error, setError] = useState<string>();
const history = useHistory();
const generateMnemonic = async () => {
const mnemonicPhrase = await createMnemonic();
setMnemonic(mnemonicPhrase);
};
useEffect(() => {
history.push('/welcome');
}, []);
useEffect(() => {
if (mnemonic.length > 0) {
const mnemonicArray = mnemonicToArray(mnemonic);
@@ -54,7 +47,7 @@ export const SignInProvider: React.FC = ({ children }) => {
};
return (
<SignInContext.Provider
<AuthContext.Provider
value={useMemo(
() => ({
error,
@@ -71,6 +64,6 @@ export const SignInProvider: React.FC = ({ children }) => {
)}
>
{children}
</SignInContext.Provider>
</AuthContext.Provider>
);
};
+3
View File
@@ -0,0 +1,3 @@
export * from './main';
export * from './auth';
export * from './accounts';
+71 -26
View File
@@ -1,8 +1,7 @@
import React, { useMemo, createContext, useEffect, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { useSnackbar } from 'notistack';
import { TLoginType } from 'src/pages/sign-in/types';
import { Account, AppEnv, Network, TCurrency, TMixnodeBondDetails } from '../types';
import { Account, Network, TCurrency, TMixnodeBondDetails, AccountEntry, AppEnv } from '../types';
import { TUseuserBalance, useGetBalance } from '../hooks/useGetBalance';
import {
getMixnodeBondDetails,
@@ -10,7 +9,9 @@ import {
signInWithMnemonic,
signInWithPassword,
signOut,
switchAccount,
getEnv,
listAccounts,
} from '../requests';
import { currencyMap } from '../utils';
import { Console } from '../utils/console';
@@ -26,10 +27,13 @@ export const urls = (networkName?: Network) =>
networkExplorer: `https://${networkName}-explorer.nymtech.net`,
};
type TClientContext = {
type TLoginType = 'mnemonic' | 'password';
type TAppContext = {
mode: 'light' | 'dark';
appEnv?: AppEnv;
clientDetails?: Account;
storedAccounts?: AccountEntry[];
mixnodeDetails?: TMixnodeBondDetails | null;
userBalance: TUseuserBalance;
showAdmin: boolean;
@@ -40,30 +44,34 @@ type TClientContext = {
isLoading: boolean;
isAdminAddress: boolean;
error?: string;
loginType?: TLoginType;
setIsLoading: (isLoading: boolean) => void;
setError: (value?: string) => void;
switchNetwork: (network: Network) => void;
getBondDetails: () => Promise<void>;
handleShowSettings: () => void;
handleShowAdmin: () => void;
logIn: (opts: { type: TLoginType; value: string }) => void;
handleShowTerminal: () => void;
logIn: (opts: { type: 'mnemonic' | 'password'; value: string }) => void;
signInWithPassword: (password: string) => void;
logOut: () => void;
onAccountChange: (accountId: string) => void;
};
export const ClientContext = createContext({} as TClientContext);
export const AppContext = createContext({} as TAppContext);
export const ClientContextProvider = ({ children }: { children: React.ReactNode }) => {
const [appEnv, setAppEnv] = useState<AppEnv>();
export const AppProvider = ({ children }: { children: React.ReactNode }) => {
const [clientDetails, setClientDetails] = useState<Account>();
const [storedAccounts, setStoredAccounts] = useState<AccountEntry[]>();
const [mixnodeDetails, setMixnodeDetails] = useState<TMixnodeBondDetails | null>();
const [network, setNetwork] = useState<Network | undefined>();
const [appEnv, setAppEnv] = useState<AppEnv>();
const [currency, setCurrency] = useState<TCurrency>();
const [showAdmin, setShowAdmin] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [showTerminal, setShowTerminal] = useState(false);
const [mode] = useState<'light' | 'dark'>('light');
const [loginType, setLoginType] = useState<'mnemonic' | 'password'>();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string>();
@@ -71,6 +79,15 @@ export const ClientContextProvider = ({ children }: { children: React.ReactNode
const history = useHistory();
const { enqueueSnackbar } = useSnackbar();
const clearState = () => {
userBalance.clearAll();
setStoredAccounts(undefined);
setNetwork(undefined);
setError(undefined);
setIsLoading(false);
setMixnodeDetails(undefined);
};
const loadAccount = async (n: Network) => {
try {
const client = await selectNetwork(n);
@@ -83,6 +100,11 @@ export const ClientContextProvider = ({ children }: { children: React.ReactNode
}
};
const loadStoredAccounts = async () => {
const accounts = await listAccounts();
setStoredAccounts(accounts);
};
const getBondDetails = async () => {
setMixnodeDetails(undefined);
try {
@@ -93,19 +115,25 @@ export const ClientContextProvider = ({ children }: { children: React.ReactNode
}
};
useEffect(() => {
getEnv().then(setAppEnv);
}, []);
const refreshAccount = async (_network: Network) => {
await loadAccount(_network);
if (loginType === 'password') {
await loadStoredAccounts();
}
};
useEffect(() => {
const refreshAccount = async () => {
if (network) {
await loadAccount(network);
await getBondDetails();
await userBalance.fetchBalance();
}
};
refreshAccount();
if (!clientDetails) {
clearState();
history.push('/');
}
}, [clientDetails]);
useEffect(() => {
if (network) {
refreshAccount(network);
getEnv().then(setAppEnv);
}
}, [network]);
const logIn = async ({ type, value }: { type: TLoginType; value: string }) => {
@@ -117,8 +145,10 @@ export const ClientContextProvider = ({ children }: { children: React.ReactNode
setIsLoading(true);
if (type === 'mnemonic') {
await signInWithMnemonic(value);
setLoginType('mnemonic');
} else {
await signInWithPassword(value);
setLoginType('password');
}
setNetwork('MAINNET');
history.push('/balance');
@@ -130,16 +160,26 @@ export const ClientContextProvider = ({ children }: { children: React.ReactNode
};
const logOut = async () => {
userBalance.clearAll();
setClientDetails(undefined);
setNetwork(undefined);
setError(undefined);
setIsLoading(false);
setMixnodeDetails(undefined);
await signOut();
setClientDetails(undefined);
enqueueSnackbar('Successfully logged out', { variant: 'success' });
};
const onAccountChange = async (accountId: string) => {
if (network) {
setIsLoading(true);
try {
await switchAccount(accountId);
await loadAccount(network);
enqueueSnackbar('Account switch success', { variant: 'success', preventDuplicate: true });
} catch (e) {
enqueueSnackbar(`Error swtiching account: ${e}`, { variant: 'error' });
} finally {
setIsLoading(false);
}
}
};
const handleShowAdmin = () => setShowAdmin((show) => !show);
const handleShowSettings = () => setShowSettings((show) => !show);
const handleShowTerminal = () => setShowTerminal((show) => !show);
@@ -153,6 +193,7 @@ export const ClientContextProvider = ({ children }: { children: React.ReactNode
isLoading,
error,
clientDetails,
storedAccounts,
mixnodeDetails,
userBalance,
showAdmin,
@@ -160,6 +201,7 @@ export const ClientContextProvider = ({ children }: { children: React.ReactNode
showTerminal,
network,
currency,
loginType,
setIsLoading,
setError,
signInWithPassword,
@@ -170,8 +212,10 @@ export const ClientContextProvider = ({ children }: { children: React.ReactNode
handleShowTerminal,
logIn,
logOut,
onAccountChange,
}),
[
loginType,
mode,
appEnv,
isLoading,
@@ -181,11 +225,12 @@ export const ClientContextProvider = ({ children }: { children: React.ReactNode
userBalance,
showAdmin,
showSettings,
showTerminal,
network,
currency,
storedAccounts,
showTerminal,
],
);
return <ClientContext.Provider value={memoizedValue}>{children}</ClientContext.Provider>;
return <AppContext.Provider value={memoizedValue}>{children}</AppContext.Provider>;
};
+84
View File
@@ -0,0 +1,84 @@
import React, { useMemo, useState } from 'react';
import { AccountEntry } from 'src/types';
import { AccountsContext, TAccountMnemonic, TAccountsDialog } from '../accounts';
export const MockAccountsProvider: React.FC = ({ children }) => {
const [accounts, setAccounts] = useState<AccountEntry[]>([{ id: 'Account_1', address: 'abc123' }]);
const [selectedAccount, setSelectedAccount] = useState<AccountEntry | undefined>({
id: 'Account_1',
address: 'abc123',
});
const [accountToEdit, setAccountToEdit] = useState<AccountEntry>();
const [dialogToDisplay, setDialogToDisplay] = useState<TAccountsDialog>();
const [accountMnemonic, setAccountMnemonic] = useState<TAccountMnemonic>({
value: undefined,
accountName: undefined,
});
const [error, setError] = useState<string>();
const [isLoading, setIsLoading] = useState(false);
const handleAddAccount = async ({ accountName }: { accountName: string; mnemonic: string; password: string }) => {
setIsLoading(true);
try {
setAccounts((accs) => [...accs, { address: 'abc123', id: accountName }]);
setDialogToDisplay('Accounts');
} catch (e) {
setError(`Error adding account: ${e}`);
} finally {
setIsLoading(false);
}
};
const handleEditAccount = (account: AccountEntry) =>
setAccounts((accs) => accs?.map((acc) => (acc.address === account.address ? account : acc)));
const handleImportAccount = (account: AccountEntry) => setAccounts((accs) => [...(accs ? [...accs] : []), account]);
const handleAccountToEdit = (accountName: string) =>
setAccountToEdit(accounts?.find((acc) => acc.id === accountName));
const handleSelectAccount = async (accountName: string) => {
if (accountName !== selectedAccount?.id) {
const match = accounts?.find((acc) => acc.id === accountName);
setSelectedAccount(match);
}
};
const handleGetAcccountMnemonic = async ({ accountName }: { password: string; accountName: string }) => {
try {
setIsLoading(true);
const mnemonic = 'test mnemonic';
setAccountMnemonic({ value: mnemonic, accountName });
} catch (e) {
setError(e as string);
} finally {
setIsLoading(false);
}
};
return (
<AccountsContext.Provider
value={useMemo(
() => ({
error,
setError,
accounts,
selectedAccount,
accountToEdit,
dialogToDisplay,
accountMnemonic,
setDialogToDisplay,
setAccountMnemonic,
isLoading,
handleAddAccount,
handleEditAccount,
handleAccountToEdit,
handleSelectAccount,
handleImportAccount,
handleGetAcccountMnemonic,
}),
[accounts, selectedAccount, accountToEdit, dialogToDisplay, isLoading, error, accountMnemonic],
)}
>
{children}
</AccountsContext.Provider>
);
};
+2 -2
View File
@@ -1,6 +1,6 @@
import { useCallback, useContext, useEffect, useState } from 'react';
import { Console } from '../utils/console';
import { ClientContext } from '../context/main';
import { AppContext } from '../context/main';
import { checkGatewayOwnership, checkMixnodeOwnership, getVestingPledgeInfo } from '../requests';
import { EnumNodeType, TNodeOwnership } from '../types';
@@ -11,7 +11,7 @@ const initial = {
};
export const useCheckOwnership = () => {
const { clientDetails } = useContext(ClientContext);
const { clientDetails } = useContext(AppContext);
const [ownership, setOwnership] = useState<TNodeOwnership>(initial);
const [isLoading, setIsLoading] = useState(true);
+1 -3
View File
@@ -92,9 +92,7 @@ export const useGetBalance = (address?: string): TUseuserBalance => {
} catch (err) {
setError(err as string);
} finally {
setTimeout(() => {
setIsLoading(false);
}, 1000);
setIsLoading(false);
}
}, []);
+22 -43
View File
@@ -1,60 +1,39 @@
import React, { useContext, useEffect } from 'react';
import React, { useEffect } from 'react';
import ReactDOM from 'react-dom';
import { ErrorBoundary } from 'react-error-boundary';
import { BrowserRouter as Router } from 'react-router-dom';
import { SnackbarProvider } from 'notistack';
import { AppRoutes, SignInRoutes } from './routes';
import { ClientContext, ClientContextProvider } from './context/main';
import { ApplicationLayout } from './layouts';
import { Admin, Settings } from './pages';
import { Routes } from './routes';
import { AppProvider } from './context/main';
import { ErrorFallback } from './components';
import { NymWalletTheme, WelcomeTheme } from './theme';
import { NymWalletTheme } from './theme';
import { maximizeWindow } from './utils';
import { SignInProvider } from './pages/sign-in/context';
import { Terminal } from './pages/terminal';
const App = () => {
const { clientDetails } = useContext(ClientContext);
useEffect(() => {
maximizeWindow();
}, []);
return !clientDetails ? (
<WelcomeTheme>
<SignInProvider>
<SignInRoutes />
</SignInProvider>
</WelcomeTheme>
) : (
<NymWalletTheme>
<ApplicationLayout>
<Settings />
<Admin />
<Terminal />
<AppRoutes />
</ApplicationLayout>
</NymWalletTheme>
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Router>
<SnackbarProvider
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
>
<AppProvider>
<NymWalletTheme>
<Routes />
</NymWalletTheme>
</AppProvider>
</SnackbarProvider>
</Router>
</ErrorBoundary>
);
};
const AppWrapper = () => (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Router>
<SnackbarProvider
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
>
<ClientContextProvider>
<App />
</ClientContextProvider>
</SnackbarProvider>
</Router>
</ErrorBoundary>
);
const root = document.getElementById('root');
ReactDOM.render(<AppWrapper />, root);
ReactDOM.render(<App />, root);
+36 -28
View File
@@ -1,44 +1,52 @@
import React from 'react';
import React, { useContext } from 'react';
import { NymWordmark } from '@nymproject/react';
import { Box, Container } from '@mui/material';
import { useTheme } from '@mui/material/styles';
import { AppBar, Nav } from '../components';
import { AppContext } from 'src/context';
import { Settings } from 'src/pages';
import { AppBar, LoadingPage, Nav } from '../components';
export const ApplicationLayout: React.FC = ({ children }) => {
const theme = useTheme();
const { isLoading, showSettings } = useContext(AppContext);
return (
<Box
sx={{
height: '100vh',
width: '100vw',
display: 'grid',
gridTemplateColumns: '240px auto',
gridTemplateRows: '100%',
overflow: 'hidden',
}}
>
<>
{isLoading && <LoadingPage />}
{showSettings && <Settings />}
<Box
sx={{
background: '#121726',
overflow: 'auto',
py: 4,
px: 5,
height: '100vh',
width: '100vw',
display: 'grid',
gridTemplateColumns: '240px auto',
gridTemplateRows: '100%',
overflow: 'hidden',
}}
display="flex"
flexDirection="column"
justifyContent="space-between"
>
<Box>
<Box sx={{ mb: 4 }}>
<NymWordmark height={14} fill={theme.palette.background.paper} />
<Box
sx={{
background: '#121726',
overflow: 'auto',
py: 3,
px: 5,
}}
display="flex"
flexDirection="column"
justifyContent="space-between"
>
<Box>
<Box sx={{ mb: 4 }}>
<NymWordmark height={14} fill={theme.palette.background.paper} />
</Box>
<Nav />
</Box>
<Nav />
</Box>
<Container>
<AppBar />
{children}
</Container>
</Box>
<Container>
<AppBar />
{children}
</Container>
</Box>
</>
);
};
+41
View File
@@ -0,0 +1,41 @@
import React, { useContext } from 'react';
import { NymWordmark } from '@nymproject/react';
import { Stack, Box } from '@mui/material';
import { AppContext } from 'src/context';
import { LoadingPage } from 'src/components';
import { Step } from '../pages/auth/components/step';
export const AuthLayout: React.FC = ({ children }) => {
const { isLoading } = useContext(AppContext);
return isLoading ? (
<LoadingPage />
) : (
<Box
sx={{
height: '100vh',
width: '100vw',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
overflow: 'auto',
bgcolor: 'nym.background.dark',
}}
>
<Box
sx={{
width: '100%',
display: 'flex',
justifyContent: 'center',
margin: 'auto',
}}
>
<Stack spacing={3} alignItems="center" sx={{ width: 1080 }}>
<NymWordmark width={75} />
<Step />
{children}
</Stack>
</Box>
</Box>
);
};
+2 -2
View File
@@ -2,7 +2,7 @@ import React, { useContext, useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { Backdrop, Box, Button, CircularProgress, FormControl, Grid, Paper, Slide, TextField } from '@mui/material';
import { ClientContext } from '../../context/main';
import { AppContext } from '../../context/main';
import { NymCard } from '../../components';
import { getContractParams, setContractParams } from '../../requests';
import { TauriContractStateParams } from '../../types';
@@ -99,7 +99,7 @@ const AdminForm: React.FC<{
};
export const Admin: React.FC = () => {
const { showAdmin, handleShowAdmin } = useContext(ClientContext);
const { showAdmin, handleShowAdmin } = useContext(AppContext);
const [isLoading, setIsLoading] = useState(false);
const [params, setParams] = useState<TauriContractStateParams>();
@@ -1,8 +1,4 @@
export * from './heading';
export * from './word-tiles';
export * from './render-page';
export * from './password-strength';
export * from './error';
export * from './textfields';
export * from './step';
export * from './page-layout';
+9
View File
@@ -0,0 +1,9 @@
import React from 'react';
import { AuthProvider } from 'src/context';
import { AuthRoutes } from 'src/routes/auth';
export const Auth = () => (
<AuthProvider>
<AuthRoutes />
</AuthProvider>
);
@@ -2,11 +2,12 @@ import React, { useContext, useEffect, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { Button, Stack } from '@mui/material';
import { validateMnemonic } from 'src/requests';
import { MnemonicInput, Subtitle } from '../components';
import { SignInContext } from '../context';
import { MnemonicInput } from 'src/components';
import { AuthContext } from 'src/context/auth';
import { Subtitle } from '../components';
export const ConfirmMnemonic = () => {
const { error, setError, setMnemonic, mnemonic } = useContext(SignInContext);
const { error, setError, setMnemonic, mnemonic } = useContext(AuthContext);
const [localMnemonic, setLocalMnemonic] = useState(mnemonic);
const history = useHistory();
@@ -2,17 +2,17 @@ import React, { useContext, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { Button, CircularProgress, FormControl, Stack } from '@mui/material';
import { useSnackbar } from 'notistack';
import { AuthContext } from 'src/context/auth';
import { createPassword } from 'src/requests';
import { PasswordInput } from 'src/components';
import { Subtitle, Title, PasswordStrength } from '../components';
import { PasswordInput } from '../components/textfields';
import { SignInContext } from '../context';
import { createPassword } from '../../../requests';
export const ConnectPassword = () => {
const [confirmedPassword, setConfirmedPassword] = useState<string>('');
const [isStrongPassword, setIsStrongPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { mnemonic, password, setPassword, resetState } = useContext(SignInContext);
const { mnemonic, password, setPassword, resetState } = useContext(AuthContext);
const history = useHistory();
const { enqueueSnackbar } = useSnackbar();
@@ -1,13 +1,13 @@
import React, { useContext, useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import { Alert, Button, Stack, Typography } from '@mui/material';
import { AuthContext } from 'src/context/auth';
import { Check, ContentCopySharp } from '@mui/icons-material';
import { useClipboard } from 'use-clipboard-copy';
import { WordTiles } from '../components';
import { SignInContext } from '../context';
export const CreateMnemonic = () => {
const { mnemonic, mnemonicWords, generateMnemonic, resetState } = useContext(SignInContext);
const { mnemonic, mnemonicWords, generateMnemonic, resetState } = useContext(AuthContext);
const history = useHistory();
useEffect(() => {
@@ -2,18 +2,17 @@ import React, { useContext, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { Button, FormControl, Stack } from '@mui/material';
import { useSnackbar } from 'notistack';
import { AuthContext } from 'src/context/auth';
import { createPassword } from 'src/requests';
import { PasswordInput } from 'src/components';
import { Subtitle, Title, PasswordStrength } from '../components';
import { PasswordInput } from '../components/textfields';
import { SignInContext } from '../context';
import { createPassword } from '../../../requests';
export const CreatePassword = () => {
const { password, setPassword, resetState } = useContext(SignInContext);
const { password, setPassword, resetState, mnemonic } = useContext(AuthContext);
const [confirmedPassword, setConfirmedPassword] = useState<string>('');
const [isStrongPassword, setIsStrongPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { mnemonic } = useContext(SignInContext);
const history = useHistory();
const handleSkip = () => {
@@ -19,7 +19,7 @@ export const ExistingAccount = () => {
Sign in with password
</Button>
<Box display="flex" justifyContent="space-between">
<Button color="inherit" onClick={() => history.push('/welcome')}>
<Button color="inherit" onClick={() => history.push('/')}>
Back
</Button>
<Button color="info" onClick={() => history.push('/sign-in-mnemonic')}>
@@ -1,14 +1,16 @@
import React, { useContext, useState, useEffect } from 'react';
import { Box, Button, FormControl, LinearProgress, Stack } from '@mui/material';
import { useHistory } from 'react-router-dom';
import { Box, Button, FormControl, Stack } from '@mui/material';
import { AppContext } from 'src/context';
import { isPasswordCreated } from 'src/requests';
import { MnemonicInput, Subtitle } from '../components';
import { ClientContext } from '../../../context/main';
import { MnemonicInput } from 'src/components';
import { Subtitle } from '../components';
export const SignInMnemonic = () => {
const [mnemonic, setMnemonic] = useState('');
const { setError, logIn, error, isLoading } = useContext(ClientContext);
const [passwordExists, setPasswordExists] = useState(true);
const { setError, logIn, error } = useContext(AppContext);
const history = useHistory();
const checkForPassword = async () => {
@@ -25,13 +27,6 @@ export const SignInMnemonic = () => {
checkForPassword();
}, []);
if (isLoading)
return (
<Box width="25%">
<LinearProgress variant="indeterminate" />
</Box>
);
return (
<Stack spacing={2} alignItems="center" minWidth="50%">
<Subtitle subtitle="Enter a mnemonic to sign in" />
@@ -1,21 +1,15 @@
import React, { useContext, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { Box, Button, LinearProgress, FormControl, Stack } from '@mui/material';
import { PasswordInput, Subtitle } from '../components';
import { ClientContext } from '../../../context/main';
import { Box, Button, FormControl, Stack } from '@mui/material';
import { PasswordInput } from 'src/components';
import { Subtitle } from '../components';
import { AppContext } from '../../../context/main';
export const SignInPassword = () => {
const [password, setPassword] = useState('');
const { setError, logIn, error, isLoading } = useContext(ClientContext);
const { setError, logIn, error } = useContext(AppContext);
const history = useHistory();
if (isLoading)
return (
<Box width="25%">
<LinearProgress variant="indeterminate" />
</Box>
);
return (
<Stack spacing={2} alignItems="center" minWidth="50%">
<Subtitle subtitle="Enter a password to sign in" />
@@ -1,10 +1,10 @@
import React, { useContext, useEffect, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { Button, Stack } from '@mui/material';
import { AuthContext } from 'src/context/auth';
import { randomNumberBetween } from 'src/utils';
import { HiddenWords, Subtitle, Title, WordTiles } from '../components';
import { THiddenMnemonicWord, THiddenMnemonicWords, TMnemonicWord, TMnemonicWords } from '../types';
import { randomNumberBetween } from '../../../utils';
import { SignInContext } from '../context';
const numberOfRandomWords = 6;
@@ -13,7 +13,7 @@ export const VerifyMnemonic = () => {
const [hiddenRandomWords, setHiddenRandomWords] = useState<THiddenMnemonicWords>();
const [currentSelection, setCurrentSelection] = useState(0);
const { mnemonicWords } = useContext(SignInContext);
const { mnemonicWords } = useContext(AuthContext);
const history = useHistory();
useEffect(() => {
@@ -19,5 +19,3 @@ export type TMnemonicWords = TMnemonicWord[];
export type THiddenMnemonicWord = { hidden: boolean } & TMnemonicWord;
export type THiddenMnemonicWords = THiddenMnemonicWord[];
export type TLoginType = 'mnemonic' | 'password';
+2 -2
View File
@@ -2,10 +2,10 @@ import React, { useContext, useEffect } from 'react';
import { Alert, Button, Grid, Link, Typography } from '@mui/material';
import { OpenInNew } from '@mui/icons-material';
import { NymCard, ClientAddress } from '../../components';
import { ClientContext, urls } from '../../context/main';
import { AppContext, urls } from '../../context/main';
export const BalanceCard = () => {
const { userBalance, clientDetails, network } = useContext(ClientContext);
const { userBalance, clientDetails, network } = useContext(AppContext);
useEffect(() => {
userBalance.fetchBalance();
@@ -2,7 +2,7 @@
import React, { useContext } from 'react';
import { Box, Tooltip, Typography } from '@mui/material';
import { format } from 'date-fns';
import { ClientContext } from '../../../context/main';
import { AppContext } from '../../../context/main';
const calculateMarkerPosition = (arrLength: number, index: number) => (1 / arrLength) * 100 * index;
@@ -19,7 +19,7 @@ const Marker: React.FC<{ tooltipText: string; color: string; position: string }>
export const VestingTimeline: React.FC<{ percentageComplete: number }> = ({ percentageComplete }) => {
const {
userBalance: { currentVestingPeriod, vestingAccountInfo },
} = useContext(ClientContext);
} = useContext(AppContext);
const nextPeriod =
typeof currentVestingPeriod === 'object' && !!vestingAccountInfo?.periods
+2 -2
View File
@@ -2,11 +2,11 @@ import React, { useContext, useEffect } from 'react';
import { Box } from '@mui/material';
import { BalanceCard } from './balance';
import { VestingCard } from './vesting';
import { ClientContext } from '../../context/main';
import { AppContext } from '../../context/main';
import { PageLayout } from '../../layouts';
export const Balance = () => {
const { userBalance } = useContext(ClientContext);
const { userBalance } = useContext(AppContext);
useEffect(() => {
userBalance.fetchBalance();
+5 -5
View File
@@ -16,7 +16,7 @@ import {
import { InfoOutlined, Refresh } from '@mui/icons-material';
import { useSnackbar } from 'notistack';
import { Fee, InfoTooltip, NymCard, Title } from '../../components';
import { ClientContext } from '../../context/main';
import { AppContext } from '../../context/main';
import { withdrawVestedCoins } from '../../requests';
import { Period } from '../../types';
import { VestingTimeline } from './components/vesting-timeline';
@@ -38,7 +38,7 @@ const vestingPeriod = (current?: Period, original?: number) => {
};
const VestingSchedule = () => {
const { userBalance, currency } = useContext(ClientContext);
const { userBalance, currency } = useContext(AppContext);
const [vestedPercentage, setVestedPercentage] = useState(0);
const calculatePercentage = () => {
@@ -93,7 +93,7 @@ const VestingSchedule = () => {
};
const TokenTransfer = () => {
const { userBalance, currency } = useContext(ClientContext);
const { userBalance, currency } = useContext(AppContext);
const icon = useCallback(
() => (
<Box sx={{ display: 'flex', mr: 1 }}>
@@ -123,7 +123,7 @@ const TokenTransfer = () => {
export const VestingCard = () => {
const [isLoading, setIsLoading] = useState(false);
const { userBalance } = useContext(ClientContext);
const { userBalance } = useContext(AppContext);
const { enqueueSnackbar, closeSnackbar } = useSnackbar();
const refreshBalances = async () => {
@@ -137,7 +137,7 @@ export const VestingCard = () => {
<NymCard
title="Vesting Schedule"
data-testid="check-unvested-tokens"
Icon={InfoOutlined}
Icon={<InfoOutlined />}
Action={
<IconButton
onClick={async () => {
+2 -2
View File
@@ -16,7 +16,7 @@ import { NodeTypeSelector } from '../../components/NodeTypeSelector';
import { bond, vestingBond, majorToMinor } from '../../requests';
import { validationSchema } from './validationSchema';
import { Gateway, MixNode, TBondArgs, EnumNodeType } from '../../types';
import { ClientContext } from '../../context/main';
import { AppContext } from '../../context/main';
import { Fee, TokenPoolSelector } from '../../components';
type TBondFormFields = {
@@ -96,7 +96,7 @@ export const BondForm = ({
defaultValues,
});
const { userBalance, currency, clientDetails } = useContext(ClientContext);
const { userBalance, currency, clientDetails } = useContext(AppContext);
useEffect(() => {
reset();
+2 -2
View File
@@ -1,11 +1,11 @@
import React, { useContext } from 'react';
import { Box } from '@mui/material';
import { SuccessReponse, TransactionDetails } from '../../components';
import { ClientContext } from '../../context/main';
import { AppContext } from '../../context/main';
import { useCheckOwnership } from '../../hooks/useCheckOwnership';
export const SuccessView: React.FC<{ details?: { amount: string; address: string } }> = ({ details }) => {
const { userBalance, currency } = useContext(ClientContext);
const { userBalance, currency } = useContext(AppContext);
const { ownership } = useCheckOwnership();
return (
@@ -4,7 +4,7 @@ import { yupResolver } from '@hookform/resolvers/yup';
import { useForm } from 'react-hook-form';
import { DelegationResult, EnumNodeType, TDelegateArgs } from '../../types';
import { validationSchema } from './validationSchema';
import { ClientContext } from '../../context/main';
import { AppContext } from '../../context/main';
import { delegate, majorToMinor, vestingDelegateToMixnode } from '../../requests';
import { Fee, TokenPoolSelector } from '../../components';
import { Console } from '../../utils/console';
@@ -41,7 +41,7 @@ export const DelegateForm = ({
resolver: yupResolver(validationSchema),
});
const { userBalance, currency, clientDetails } = useContext(ClientContext);
const { userBalance, currency, clientDetails } = useContext(AppContext);
useEffect(() => {
reset();
@@ -1,10 +1,10 @@
import React, { useContext } from 'react';
import { Box, Stack, Typography } from '@mui/material';
import { SuccessReponse, TransactionDetails } from '../../components';
import { ClientContext } from '../../context/main';
import { AppContext } from '../../context/main';
export const SuccessView: React.FC<{ details?: { amount: string; address: string } }> = ({ details }) => {
const { userBalance, currency } = useContext(ClientContext);
const { userBalance, currency } = useContext(AppContext);
return (
<>
<SuccessReponse
+2 -2
View File
@@ -4,7 +4,7 @@ import { DelegateForm } from './DelegateForm';
import { NymCard } from '../../components';
import { EnumRequestStatus, RequestStatus } from '../../components/RequestStatus';
import { SuccessView } from './SuccessView';
import { urls, ClientContext } from '../../context/main';
import { urls, AppContext } from '../../context/main';
import { PageLayout } from '../../layouts';
export const Delegate = () => {
@@ -12,7 +12,7 @@ export const Delegate = () => {
const [error, setError] = useState<string>();
const [successDetails, setSuccessDetails] = useState<{ amount: string; address: string }>();
const { network } = useContext(ClientContext);
const { network } = useContext(AppContext);
return (
<PageLayout>
+1 -1
View File
@@ -5,7 +5,7 @@ export * from './delegate';
export * from './internal-docs';
export * from './receive';
export * from './send';
export * from './sign-in';
export * from './auth';
export * from './settings';
export * from './unbond';
export * from './undelegate';
+2 -2
View File
@@ -2,10 +2,10 @@ import React, { useContext } from 'react';
import { NymCard } from '../../components';
import { ApiList } from './ApiList';
import { ClientContext } from '../../context/main';
import { AppContext } from '../../context/main';
export const InternalDocs = () => {
const { isAdminAddress } = useContext(ClientContext);
const { isAdminAddress } = useContext(AppContext);
if (!isAdminAddress) {
return null;
+2 -2
View File
@@ -2,11 +2,11 @@ import React, { useContext } from 'react';
import QRCode from 'qrcode.react';
import { Alert, Box, Stack } from '@mui/material';
import { ClientAddress, NymCard } from '../../components';
import { ClientContext } from '../../context/main';
import { AppContext } from '../../context/main';
import { PageLayout } from '../../layouts';
export const Receive = () => {
const { clientDetails, currency } = useContext(ClientContext);
const { clientDetails, currency } = useContext(AppContext);
return (
<PageLayout>
@@ -1,7 +1,7 @@
import React, { useContext } from 'react';
import { Box, CircularProgress, Link, Typography } from '@mui/material';
import { SendError } from './SendError';
import { ClientContext, urls } from '../../context/main';
import { AppContext, urls } from '../../context/main';
import { SuccessReponse } from '../../components';
import { TransactionDetails } from '../../components/TransactionDetails';
import { TransactionDetails as TTransactionDetails } from '../../types';
@@ -15,7 +15,7 @@ export const SendConfirmation = ({
error?: string;
isLoading: boolean;
}) => {
const { userBalance, currency, network } = useContext(ClientContext);
const { userBalance, currency, network } = useContext(AppContext);
if (!data && !error && !isLoading) return null;
+2 -2
View File
@@ -1,7 +1,7 @@
import React, { useContext } from 'react';
import { Grid, InputAdornment, TextField } from '@mui/material';
import { useFormContext } from 'react-hook-form';
import { ClientContext } from '../../context/main';
import { AppContext } from '../../context/main';
import { Fee } from '../../components';
export const SendForm = () => {
@@ -9,7 +9,7 @@ export const SendForm = () => {
register,
formState: { errors },
} = useFormContext();
const { currency } = useContext(ClientContext);
const { currency } = useContext(AppContext);
return (
<Grid container spacing={3}>
+2 -2
View File
@@ -1,7 +1,7 @@
import React, { useContext } from 'react';
import { Card, Divider, Grid, Typography } from '@mui/material';
import { useFormContext } from 'react-hook-form';
import { ClientContext } from '../../context/main';
import { AppContext } from '../../context/main';
const SendReviewField = ({ title, subtitle, info }: { title: string; subtitle?: string; info?: boolean }) => (
<>
@@ -14,7 +14,7 @@ const SendReviewField = ({ title, subtitle, info }: { title: string; subtitle?:
export const SendReview = ({ transferFee }: { transferFee?: string }) => {
const { getValues } = useFormContext();
const { clientDetails, currency } = useContext(ClientContext);
const { clientDetails, currency } = useContext(AppContext);
const values = getValues();
+2 -2
View File
@@ -6,7 +6,7 @@ import { Box, Button, Step, StepLabel, Stepper } from '@mui/material';
import { SendForm } from './SendForm';
import { SendReview } from './SendReview';
import { SendConfirmation } from './SendConfirmation';
import { ClientContext } from '../../context/main';
import { AppContext } from '../../context/main';
import { validationSchema } from './validationSchema';
import { TauriTxResult, TransactionDetails } from '../../types';
import { getGasFee, majorToMinor, send } from '../../requests';
@@ -32,7 +32,7 @@ export const SendWizard = () => {
const [transferFee, setTransferFee] = useState<string>();
const [confirmedData, setConfirmedData] = useState<TransactionDetails & { tx_hash: string }>();
const { userBalance } = useContext(ClientContext);
const { userBalance } = useContext(AppContext);
useEffect(() => {
const getFee = async () => {
+2 -2
View File
@@ -1,11 +1,11 @@
import React, { useContext } from 'react';
import { NymCard } from '../../components';
import { SendWizard } from './SendWizard';
import { ClientContext } from '../../context/main';
import { AppContext } from '../../context/main';
import { PageLayout } from '../../layouts';
export const Send = () => {
const { currency } = useContext(ClientContext);
const { currency } = useContext(AppContext);
return (
<PageLayout>
<NymCard title={`Send ${currency?.major}`} noPadding>
+2 -2
View File
@@ -1,7 +1,7 @@
import React, { useContext, useEffect, useState } from 'react';
import { Alert, Box, Dialog } from '@mui/material';
import { NymCard } from '../../components';
import { ClientContext } from '../../context/main';
import { AppContext } from '../../context/main';
import { Tabs } from './tabs';
import { Profile } from './profile';
import { SystemVariables } from './system-variables';
@@ -15,7 +15,7 @@ const tabs = ['Profile', 'System variables', 'Node stats'];
export const Settings = () => {
const [selectedTab, setSelectedTab] = useState(0);
const { mixnodeDetails, showSettings, getBondDetails, handleShowSettings } = useContext(ClientContext);
const { mixnodeDetails, showSettings, getBondDetails, handleShowSettings } = useContext(AppContext);
const { status, saturation, rewardEstimation, inclusionProbability } = useSettingsState(showSettings);
const handleTabChange = (_: React.SyntheticEvent, newTab: number) => setSelectedTab(newTab);
+2 -2
View File
@@ -1,10 +1,10 @@
import React, { useContext } from 'react';
import { OpenInNew } from '@mui/icons-material';
import { Button, Link, Stack, Typography } from '@mui/material';
import { urls, ClientContext } from '../../context/main';
import { urls, AppContext } from '../../context/main';
export const NodeStats = ({ mixnodeId }: { mixnodeId?: string }) => {
const { network } = useContext(ClientContext);
const { network } = useContext(AppContext);
return (
<Stack spacing={2} sx={{ p: 4 }}>
<Typography>All your node stats are available on the link below</Typography>
+2 -2
View File
@@ -1,9 +1,9 @@
import React, { useContext } from 'react';
import { Box, Button, Divider, Stack, TextField, Typography } from '@mui/material';
import { ClientContext } from '../../context/main';
import { AppContext } from '../../context/main';
export const Profile = () => {
const { mixnodeDetails } = useContext(ClientContext);
const { mixnodeDetails } = useContext(AppContext);
if (!mixnodeDetails) return null;
@@ -10,7 +10,7 @@ import { Fee, InfoTooltip } from '../../components';
import { InclusionProbabilityResponse } from '../../types';
import { useCheckOwnership } from '../../hooks/useCheckOwnership';
import { updateMixnode, vestingUpdateMixnode } from '../../requests';
import { ClientContext } from '../../context/main';
import { AppContext } from '../../context/main';
import { Console } from '../../utils/console';
const DataField = ({ title, info, Indicator }: { title: string; info: string; Indicator: React.ReactElement }) => (
@@ -70,7 +70,6 @@ const PercentIndicator = ({ value, warning }: { value: number; warning?: boolean
export const SystemVariables = ({
saturation,
rewardEstimation,
inclusionProbability,
}: {
saturation: number;
@@ -78,7 +77,7 @@ export const SystemVariables = ({
inclusionProbability: InclusionProbabilityResponse;
}) => {
const [nodeUpdateResponse, setNodeUpdateResponse] = useState<'success' | 'failed'>();
const { mixnodeDetails } = useContext(ClientContext);
const { mixnodeDetails } = useContext(AppContext);
const { ownership } = useCheckOwnership();
const {
@@ -1,7 +1,7 @@
import { useContext, useEffect, useState } from 'react';
import { useSnackbar } from 'notistack';
import { ClientContext } from '../../context/main';
import { getMixnodeStakeSaturation, getMixnodeStatus, getInclusionProbability } from '../../requests';
import { AppContext } from '../../context/main';
import { MixnodeStatus, InclusionProbabilityResponse } from '../../types';
export const useSettingsState = (shouldUpdate: boolean) => {
@@ -13,7 +13,7 @@ export const useSettingsState = (shouldUpdate: boolean) => {
in_reserve: 'Low',
});
const { mixnodeDetails } = useContext(ClientContext);
const { mixnodeDetails } = useContext(AppContext);
const { enqueueSnackbar } = useSnackbar();
@@ -1,8 +0,0 @@
import React from 'react';
import { Alert } from '@mui/material';
export const Error = ({ message }: { message: string }) => (
<Alert severity="error" variant="outlined" data-testid="error" sx={{ color: 'error.light', width: '100%' }}>
{message}
</Alert>
);
@@ -1,33 +0,0 @@
import React from 'react';
import { Stack, Box } from '@mui/material';
import { NymWordmark } from '@nymproject/react';
import { Step } from './step';
export const PageLayout: React.FC = ({ children }) => (
<Box
sx={{
height: '100vh',
width: '100vw',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
overflow: 'auto',
bgcolor: 'nym.background.dark',
}}
>
<Box
sx={{
width: '100%',
display: 'flex',
justifyContent: 'center',
margin: 'auto',
}}
>
<Stack spacing={3} alignItems="center" sx={{ width: 1080 }}>
<NymWordmark width={75} />
<Step />
{children}
</Stack>
</Box>
</Box>
);
@@ -1,11 +0,0 @@
import React from 'react';
import { TPages } from '../types';
export const RenderPage = ({ children, page }: { children: React.ReactElement[]; page: TPages }) => (
<>
{React.Children.map(children, (Child) => {
if (page === Child?.props.page) return Child;
return null;
})}
</>
);
-1
View File
@@ -1 +0,0 @@
export * from './pages';
+3 -3
View File
@@ -4,7 +4,7 @@ import TerminalIcon from '@mui/icons-material/Terminal';
import RefreshIcon from '@mui/icons-material/Refresh';
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
import ArrowDropUpIcon from '@mui/icons-material/ArrowDropUp';
import { ClientContext } from '../../context/main';
import { AppContext } from '../../context/main';
import { NymCard } from '../../components';
import {
getCurrentEpoch,
@@ -37,7 +37,7 @@ const TerminalSection: React.FC<{
};
const TerminalInner: React.FC = () => {
const { network, userBalance, clientDetails, handleShowTerminal, appEnv } = useContext(ClientContext);
const { network, userBalance, clientDetails, handleShowTerminal, appEnv } = useContext(AppContext);
const { balance, vestingAccountInfo, currentVestingPeriod, originalVesting, fetchBalance, fetchTokenAllocation } =
useGetBalance(clientDetails?.client_address);
const [mixnodeDelegations, setMixnodeDelegations] = useState<any>();
@@ -184,7 +184,7 @@ const TerminalInner: React.FC = () => {
};
export const Terminal: React.FC = () => {
const { showTerminal } = useContext(ClientContext);
const { showTerminal } = useContext(AppContext);
// this is a guard component, that only mounts the terminal component when shown
if (!showTerminal) {
+2 -2
View File
@@ -3,14 +3,14 @@ import { Alert, Box, Button, CircularProgress } from '@mui/material';
import { useSnackbar } from 'notistack';
import { Fee, NymCard } from '../../components';
import { useCheckOwnership } from '../../hooks/useCheckOwnership';
import { ClientContext } from '../../context/main';
import { AppContext } from '../../context/main';
import { unbond, vestingUnbond } from '../../requests';
import { PageLayout } from '../../layouts';
export const Unbond = () => {
const [isLoading, setIsLoading] = useState(false);
const { checkOwnership, ownership } = useCheckOwnership();
const { userBalance, getBondDetails } = useContext(ClientContext);
const { userBalance, getBondDetails } = useContext(AppContext);
const { enqueueSnackbar } = useSnackbar();
@@ -2,7 +2,7 @@ import React, { useCallback, useContext, useEffect, useState } from 'react';
import { Table, TableCell, TableHead, TableRow } from '@mui/material';
import { minorToMajor } from 'src/requests';
import { DelegationResult } from 'src/types';
import { ClientContext } from 'src/context/main';
import { AppContext } from 'src/context';
export const PendingEvents = ({
pendingDelegations,
@@ -12,7 +12,7 @@ export const PendingEvents = ({
show: boolean;
}) => {
const [mapped, setMapped] = useState<Array<DelegationResult & { majorValue: string }>>([]);
const { currency } = useContext(ClientContext);
const { currency } = useContext(AppContext);
const mapToMajorValue = useCallback(async () => {
const mappedToMajor = await Promise.all(
+2 -2
View File
@@ -3,6 +3,7 @@ import { Alert, AlertTitle, Box, Button, CircularProgress, Grid, IconButton } fr
import { ArrowDropDown, ArrowDropUp } from '@mui/icons-material';
import { EnumRequestStatus, NymCard, RequestStatus } from '../../components';
import { UndelegateForm } from './UndelegateForm';
import { AppContext } from '../../context/main';
import {
getCurrentEpoch,
getPendingDelegations,
@@ -10,7 +11,6 @@ import {
getReverseMixDelegations,
} from '../../requests';
import { DelegationResult, Epoch, PendingUndelegate, TPagedDelegations } from '../../types';
import { ClientContext } from '../../context/main';
import { PageLayout } from '../../layouts';
import { removeObjectDuplicates } from '../../utils';
import { PendingEvents } from './PendingEvents';
@@ -25,7 +25,7 @@ export const Undelegate = () => {
const [currentEndEpoch, setCurrentEndEpoch] = useState<Epoch['end']>();
const [showPendingDelegations, setShowPendingDelegations] = useState(false);
const { clientDetails } = useContext(ClientContext);
const { clientDetails } = useContext(AppContext);
const refresh = async () => {
const mixnodeDelegations = await getReverseMixDelegations();
+46 -11
View File
@@ -1,32 +1,67 @@
import { invoke } from '@tauri-apps/api';
import { AccountEntry } from 'src/types/rust/accountentry';
import { Account } from '../types';
export const createMnemonic = async (): Promise<string> => invoke('create_new_mnemonic');
export const createMnemonic = async () => {
const res: string = await invoke('create_new_mnemonic');
return res;
};
export const createPassword = async ({ mnemonic, password }: { mnemonic: string; password: string }): Promise<void> => {
export const createPassword = async ({ mnemonic, password }: { mnemonic: string; password: string }) => {
await invoke('create_password', { mnemonic, password });
};
export const signInWithMnemonic = async (mnemonic: string): Promise<Account> => {
export const signInWithMnemonic = async (mnemonic: string) => {
const res: Account = await invoke('connect_with_mnemonic', { mnemonic });
return res;
};
export const validateMnemonic = async (mnemonic: string): Promise<boolean> => {
const res: boolean = await invoke('validate_mnemonic', { mnemonic });
return res;
};
export const signInWithPassword = async (password: string): Promise<Account> => {
export const signInWithPassword = async (password: string) => {
const res: Account = await invoke('sign_in_with_password', { password });
return res;
};
export const signOut = async (): Promise<void> => {
export const validateMnemonic = async (mnemonic: string) => {
const res: boolean = await invoke('validate_mnemonic', { mnemonic });
return res;
};
export const signOut = async () => {
await invoke('logout');
};
export const isPasswordCreated = async (): Promise<boolean> => {
export const isPasswordCreated = async () => {
const res: boolean = await invoke('does_password_file_exist');
return res;
};
export const addAccount = async ({
mnemonic,
password,
accountName,
}: {
mnemonic: string;
password: string;
accountName: string;
}) => {
const res: AccountEntry = await invoke('add_account_for_password', { mnemonic, password, accountId: accountName });
return res;
};
export const removeAccount = async ({ password, accountName }: { password: string; accountName: string }) => {
await invoke('remove_account_for_password', { password, innerId: accountName });
};
export const listAccounts = async () => {
const res: AccountEntry[] = await invoke('list_accounts');
return res;
};
export const showMnemonicForAccount = async ({ password, accountName }: { password: string; accountName: string }) => {
const res: string = await invoke('show_mnemonic_for_account_in_password', { password, accountId: accountName });
return res;
};
export const switchAccount = async (accountId: string) => {
await invoke('sign_in_decrypted_account', { accountId });
};
+32 -27
View File
@@ -1,32 +1,37 @@
import React from 'react';
import { Switch, Route } from 'react-router-dom';
import { Route, Switch } from 'react-router-dom';
import { ApplicationLayout } from 'src/layouts';
import { Terminal } from 'src/pages/terminal';
import { Bond, Balance, Delegate, InternalDocs, Receive, Send, Unbond, Undelegate } from '../pages';
export const AppRoutes = () => (
<Switch>
<Route path="/balance">
<Balance />
</Route>
<Route path="/send">
<Send />
</Route>
<Route path="/receive">
<Receive />
</Route>
<Route path="/bond">
<Bond />
</Route>
<Route path="/unbond">
<Unbond />
</Route>
<Route path="/delegate">
<Delegate />
</Route>
<Route path="/undelegate">
<Undelegate />
</Route>
<Route path="/docs">
<InternalDocs />
</Route>
</Switch>
<ApplicationLayout>
<Terminal />
<Switch>
<Route path="/balance">
<Balance />
</Route>
<Route path="/send">
<Send />
</Route>
<Route path="/receive">
<Receive />
</Route>
<Route path="/bond">
<Bond />
</Route>
<Route path="/unbond">
<Unbond />
</Route>
<Route path="/delegate">
<Delegate />
</Route>
<Route path="/undelegate">
<Undelegate />
</Route>
<Route path="/docs">
<InternalDocs />
</Route>
</Switch>
</ApplicationLayout>
);
+54
View File
@@ -0,0 +1,54 @@
import React from 'react';
import { Switch, Route } from 'react-router-dom';
import { AuthProvider } from 'src/context';
import { AuthLayout } from 'src/layouts/AuthLayout';
import {
CreateMnemonic,
CreatePassword,
ExistingAccount,
SignInMnemonic,
SignInPassword,
VerifyMnemonic,
WelcomeContent,
ConnectPassword,
} from 'src/pages/auth/pages';
import { ConfirmMnemonic } from 'src/pages/auth/pages/confirm-mnemonic';
import { AuthTheme } from 'src/theme';
export const AuthRoutes = () => (
<AuthProvider>
<AuthTheme>
<AuthLayout>
<Switch>
<Route path="/" exact>
<WelcomeContent />
</Route>
<Route path="/existing-account">
<ExistingAccount />
</Route>
<Route path="/create-mnemonic">
<CreateMnemonic />
</Route>
<Route path="/verify-mnemonic">
<VerifyMnemonic />
</Route>
<Route path="/create-password">
<CreatePassword />
</Route>
<Route path="/sign-in-mnemonic">
<SignInMnemonic />
</Route>
<Route path="/sign-in-password">
<SignInPassword />
</Route>
<Route path="/confirm-mnemonic">
<ConfirmMnemonic />
</Route>
<Route path="/connect-password">
<ConnectPassword />
</Route>
</Switch>
</AuthLayout>
</AuthTheme>
</AuthProvider>
);
+10 -2
View File
@@ -1,2 +1,10 @@
export * from './app';
export * from './sign-in';
import React, { useContext } from 'react';
import { AppContext } from 'src/context';
import { AppRoutes } from './app';
import { AuthRoutes } from './auth';
export const Routes = () => {
const { clientDetails } = useContext(AppContext);
if (!clientDetails) return <AuthRoutes />;
return <AppRoutes />;
};

Some files were not shown because too many files have changed in this diff Show More