feat(ui): add login/register dialog (#2056)

This commit is contained in:
Ellie Huxtable 2024-05-30 12:49:22 +01:00 committed by GitHub
parent 15618f19ab
commit 467f89c104
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 779 additions and 104 deletions

1
Cargo.lock generated
View File

@ -292,6 +292,7 @@ dependencies = [
"sqlx", "sqlx",
"thiserror", "thiserror",
"time", "time",
"tiny-bip39",
"tokio", "tokio",
"typed-builder", "typed-builder",
"urlencoding", "urlencoding",

View File

@ -68,6 +68,7 @@ reqwest = { workspace = true, optional = true }
hex = { version = "0.4", optional = true } hex = { version = "0.4", optional = true }
sha2 = { version = "0.10", optional = true } sha2 = { version = "0.10", optional = true }
indicatif = "0.17.7" indicatif = "0.17.7"
tiny-bip39 = "1"
[dev-dependencies] [dev-dependencies]
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }

View File

@ -13,8 +13,10 @@ pub mod encryption;
pub mod history; pub mod history;
pub mod import; pub mod import;
pub mod kv; pub mod kv;
pub mod login;
pub mod ordering; pub mod ordering;
pub mod record; pub mod record;
pub mod register;
pub mod secrets; pub mod secrets;
pub mod settings; pub mod settings;

View File

@ -0,0 +1,91 @@
use std::path::PathBuf;
use atuin_common::api::LoginRequest;
use eyre::{bail, Context, Result};
use tokio::fs::File;
use tokio::io::AsyncWriteExt;
use crate::{
api_client,
encryption::{decode_key, encode_key, load_key, Key},
record::{sqlite_store::SqliteStore, store::Store},
settings::Settings,
};
pub async fn login(
settings: &Settings,
store: &SqliteStore,
username: String,
password: String,
key: String,
) -> Result<String> {
// try parse the key as a mnemonic...
let key = match bip39::Mnemonic::from_phrase(&key, bip39::Language::English) {
Ok(mnemonic) => encode_key(Key::from_slice(mnemonic.entropy()))?,
Err(err) => {
if let Some(err) = err.downcast_ref::<bip39::ErrorKind>() {
match err {
// assume they copied in the base64 key
bip39::ErrorKind::InvalidWord => key,
bip39::ErrorKind::InvalidChecksum => {
bail!("key mnemonic was not valid")
}
bip39::ErrorKind::InvalidKeysize(_)
| bip39::ErrorKind::InvalidWordLength(_)
| bip39::ErrorKind::InvalidEntropyLength(_, _) => {
bail!("key was not the correct length")
}
}
} else {
// unknown error. assume they copied the base64 key
key
}
}
};
let key_path = settings.key_path.as_str();
let key_path = PathBuf::from(key_path);
if !key_path.exists() {
if decode_key(key.clone()).is_err() {
bail!("the specified key was invalid");
}
let mut file = File::create(key_path).await?;
file.write_all(key.as_bytes()).await?;
} else {
// we now know that the user has logged in specifying a key, AND that the key path
// exists
// 1. check if the saved key and the provided key match. if so, nothing to do.
// 2. if not, re-encrypt the local history and overwrite the key
let current_key: [u8; 32] = load_key(settings)?.into();
let encoded = key.clone(); // gonna want to save it in a bit
let new_key: [u8; 32] = decode_key(key)
.context("could not decode provided key - is not valid base64")?
.into();
if new_key != current_key {
println!("\nRe-encrypting local store with new key");
store.re_encrypt(&current_key, &new_key).await?;
println!("Writing new key");
let mut file = File::create(key_path).await?;
file.write_all(encoded.as_bytes()).await?;
}
}
let session = api_client::login(
settings.sync_address.as_str(),
LoginRequest { username, password },
)
.await?;
let session_path = settings.session_path.as_str();
let mut file = File::create(session_path).await?;
file.write_all(session.session.as_bytes()).await?;
Ok(session.session)
}

View File

@ -0,0 +1,23 @@
use eyre::Result;
use tokio::fs::File;
use tokio::io::AsyncWriteExt;
use crate::{api_client, settings::Settings};
pub async fn register(
settings: &Settings,
username: String,
email: String,
password: String,
) -> Result<String> {
let session =
api_client::register(settings.sync_address.as_str(), &username, &email, &password).await?;
let path = settings.session_path.as_str();
let mut file = File::create(path).await?;
file.write_all(session.session.as_bytes()).await?;
let _key = crate::encryption::load_key(settings)?;
Ok(session.session)
}

View File

@ -35,6 +35,12 @@ fn get_input() -> Result<String> {
impl Cmd { impl Cmd {
pub async fn run(&self, settings: &Settings, store: &SqliteStore) -> Result<()> { pub async fn run(&self, settings: &Settings, store: &SqliteStore) -> Result<()> {
// TODO(ellie): Replace this with a call to atuin_client::login::login
// The reason I haven't done this yet is that this implementation allows for
// an empty key. This will use an existing key file.
//
// I'd quite like to ditch that behaviour, so have not brought it into the library
// function.
if settings.logged_in() { if settings.logged_in() {
println!( println!(
"You are already logged in! Please run 'atuin logout' if you wish to login again" "You are already logged in! Please run 'atuin logout' if you wish to login again"

147
ui/backend/Cargo.lock generated
View File

@ -147,7 +147,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [ dependencies = [
"base64ct", "base64ct",
"blake2 0.10.6", "blake2",
"cpufeatures", "cpufeatures",
"password-hash", "password-hash",
] ]
@ -257,6 +257,7 @@ dependencies = [
"sqlx", "sqlx",
"thiserror", "thiserror",
"time", "time",
"tiny-bip39",
"tokio", "tokio",
"typed-builder", "typed-builder",
"urlencoding", "urlencoding",
@ -346,12 +347,6 @@ dependencies = [
"rustc-demangle", "rustc-demangle",
] ]
[[package]]
name = "base64"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.21.7" version = "0.21.7"
@ -400,24 +395,13 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "blake2"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a4e37d16930f5459780f5621038b6382b9bb37c19016f39fb6b5808d831f174"
dependencies = [
"crypto-mac",
"digest 0.9.0",
"opaque-debug",
]
[[package]] [[package]]
name = "blake2" name = "blake2"
version = "0.10.6" version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [ dependencies = [
"digest 0.10.7", "digest",
] ]
[[package]] [[package]]
@ -601,17 +585,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77e53693616d3075149f4ead59bdeecd204ac6b8192d8969757601b74bddf00f" checksum = "77e53693616d3075149f4ead59bdeecd204ac6b8192d8969757601b74bddf00f"
[[package]]
name = "chacha20"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c80e5460aa66fe3b91d40bcbdab953a597b60053e34d684ac6903f863b680a6"
dependencies = [
"cfg-if",
"cipher 0.3.0",
"cpufeatures",
]
[[package]] [[package]]
name = "chacha20" name = "chacha20"
version = "0.9.1" version = "0.9.1"
@ -619,7 +592,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cipher 0.4.4", "cipher",
"cpufeatures", "cpufeatures",
] ]
@ -636,15 +609,6 @@ dependencies = [
"windows-targets 0.52.5", "windows-targets 0.52.5",
] ]
[[package]]
name = "cipher"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ee52072ec15386f770805afd189a01c8841be8696bed250fa2f13c4c0d6dfb7"
dependencies = [
"generic-array",
]
[[package]] [[package]]
name = "cipher" name = "cipher"
version = "0.4.4" version = "0.4.4"
@ -934,16 +898,6 @@ dependencies = [
"typenum", "typenum",
] ]
[[package]]
name = "crypto-mac"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b584a330336237c1eecd3e94266efb216c56ed91225d634cb2991c5f3fd1aeab"
dependencies = [
"generic-array",
"subtle",
]
[[package]] [[package]]
name = "crypto_secretbox" name = "crypto_secretbox"
version = "0.1.1" version = "0.1.1"
@ -951,7 +905,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d6cf87adf719ddf43a805e92c6870a531aedda35ff640442cbaf8674e141e1" checksum = "b9d6cf87adf719ddf43a805e92c6870a531aedda35ff640442cbaf8674e141e1"
dependencies = [ dependencies = [
"aead", "aead",
"cipher 0.4.4", "cipher",
"generic-array", "generic-array",
"poly1305", "poly1305",
"salsa20", "salsa20",
@ -1005,7 +959,7 @@ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures",
"curve25519-dalek-derive", "curve25519-dalek-derive",
"digest 0.10.7", "digest",
"fiat-crypto", "fiat-crypto",
"platforms", "platforms",
"rustc_version", "rustc_version",
@ -1099,15 +1053,6 @@ dependencies = [
"syn 1.0.109", "syn 1.0.109",
] ]
[[package]]
name = "digest"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066"
dependencies = [
"generic-array",
]
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@ -2006,7 +1951,7 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [ dependencies = [
"digest 0.10.7", "digest",
] ]
[[package]] [[package]]
@ -2336,9 +2281,9 @@ checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800"
[[package]] [[package]]
name = "iso8601" name = "iso8601"
version = "0.4.2" version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5b94fbeb759754d87e1daea745bc8efd3037cd16980331fe1d1524c9a79ce96" checksum = "924e5d73ea28f59011fec52a0d12185d496a9b075d360657aed2a5707f701153"
dependencies = [ dependencies = [
"nom", "nom",
] ]
@ -2671,7 +2616,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"digest 0.10.7", "digest",
] ]
[[package]] [[package]]
@ -3147,6 +3092,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd"
[[package]]
name = "pbkdf2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917"
dependencies = [
"digest",
]
[[package]] [[package]]
name = "pem-rfc7468" name = "pem-rfc7468"
version = "0.7.0" version = "0.7.0"
@ -3806,7 +3760,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0e5124fcb30e76a7e79bfee683a2746db83784b86289f6251b54b7950a0dfc" checksum = "5d0e5124fcb30e76a7e79bfee683a2746db83784b86289f6251b54b7950a0dfc"
dependencies = [ dependencies = [
"const-oid", "const-oid",
"digest 0.10.7", "digest",
"num-bigint-dig", "num-bigint-dig",
"num-integer", "num-integer",
"num-traits", "num-traits",
@ -3825,6 +3779,12 @@ version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
[[package]]
name = "rustc-hash"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
[[package]] [[package]]
name = "rustc_version" name = "rustc_version"
version = "0.4.0" version = "0.4.0"
@ -3939,18 +3899,18 @@ checksum = "092474d1a01ea8278f69e6a358998405fae5b8b963ddaeb2b0b04a128bf1dfb0"
[[package]] [[package]]
name = "rusty_paserk" name = "rusty_paserk"
version = "0.3.0" version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56d471e07f9e792e60a4d90af4657e2d07e30f20156b689fe5bc1577ecb2e0ec" checksum = "a1d3a81ecd341ee8abf4761350ecffe3518c284ed1626bbd58f5f4bd64c61d38"
dependencies = [ dependencies = [
"argon2", "argon2",
"base64 0.13.1", "base64 0.22.1",
"base64ct", "base64ct",
"blake2 0.10.6", "blake2",
"chacha20 0.9.1", "chacha20",
"cipher 0.4.4", "cipher",
"curve25519-dalek", "curve25519-dalek",
"digest 0.10.7", "digest",
"ed25519-dalek", "ed25519-dalek",
"generic-array", "generic-array",
"rand 0.8.5", "rand 0.8.5",
@ -3962,16 +3922,18 @@ dependencies = [
[[package]] [[package]]
name = "rusty_paseto" name = "rusty_paseto"
version = "0.6.1" version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aadb59ff4f705031fae18f6a0261dae6869f70cfd5d134eac497d3841cc3644" checksum = "b03abd0624688047cc65eadc5588dd35be44151ce7b6e7e7dea4976f5b3dcd54"
dependencies = [ dependencies = [
"base64 0.13.1", "base64 0.22.1",
"blake2 0.9.2", "blake2",
"chacha20 0.8.2", "chacha20",
"digest",
"ed25519-dalek", "ed25519-dalek",
"hex", "hex",
"iso8601", "iso8601",
"rand_core 0.6.4",
"ring", "ring",
"thiserror", "thiserror",
"time", "time",
@ -3990,7 +3952,7 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
dependencies = [ dependencies = [
"cipher 0.4.4", "cipher",
] ]
[[package]] [[package]]
@ -4265,7 +4227,7 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures",
"digest 0.10.7", "digest",
] ]
[[package]] [[package]]
@ -4276,7 +4238,7 @@ checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures",
"digest 0.10.7", "digest",
] ]
[[package]] [[package]]
@ -4333,7 +4295,7 @@ version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [ dependencies = [
"digest 0.10.7", "digest",
"rand_core 0.6.4", "rand_core 0.6.4",
] ]
@ -4578,7 +4540,7 @@ dependencies = [
"byteorder", "byteorder",
"bytes", "bytes",
"crc", "crc",
"digest 0.10.7", "digest",
"dotenvy", "dotenvy",
"either", "either",
"futures-channel", "futures-channel",
@ -5280,6 +5242,25 @@ dependencies = [
"time-core", "time-core",
] ]
[[package]]
name = "tiny-bip39"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62cc94d358b5a1e84a5cb9109f559aa3c4d634d2b1b4de3d0fa4adc7c78e2861"
dependencies = [
"anyhow",
"hmac",
"once_cell",
"pbkdf2",
"rand 0.8.5",
"rustc-hash",
"sha2",
"thiserror",
"unicode-normalization",
"wasm-bindgen",
"zeroize",
]
[[package]] [[package]]
name = "tinyvec" name = "tinyvec"
version = "1.6.0" version = "1.6.0"

View File

@ -77,7 +77,41 @@ async fn config() -> Result<Settings, String> {
#[tauri::command] #[tauri::command]
async fn session() -> Result<String, String> { async fn session() -> Result<String, String> {
Settings::new().map_err(|e|e.to_string())?.session_token().map_err(|e|e.to_string()) Settings::new()
.map_err(|e| e.to_string())?
.session_token()
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn login(username: String, password: String, key: String) -> Result<String, String> {
let settings = Settings::new().map_err(|e| e.to_string())?;
let record_store_path = PathBuf::from(settings.record_store_path.as_str());
let store = SqliteStore::new(record_store_path, settings.local_timeout)
.await
.map_err(|e| e.to_string())?;
if settings.logged_in() {
return Err(String::from("Already logged in"));
}
let session = atuin_client::login::login(&settings, &store, username, password, key)
.await
.map_err(|e| e.to_string())?;
Ok(session)
}
#[tauri::command]
async fn register(username: String, email: String, password: String) -> Result<String, String> {
let settings = Settings::new().map_err(|e| e.to_string())?;
let session = atuin_client::register::register(&settings, username, email, password)
.await
.map_err(|e| e.to_string())?;
Ok(session)
} }
#[tauri::command] #[tauri::command]
@ -88,7 +122,6 @@ async fn home_info() -> Result<HomeInfo, String> {
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let last_sync = Settings::last_sync() let last_sync = Settings::last_sync()
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
.format(&Rfc3339) .format(&Rfc3339)
@ -110,7 +143,10 @@ async fn home_info() -> Result<HomeInfo, String> {
} else { } else {
let client = atuin_client::api_client::Client::new( let client = atuin_client::api_client::Client::new(
&settings.sync_address, &settings.sync_address,
settings.session_token().map_err(|e|e.to_string())?.as_str(), settings
.session_token()
.map_err(|e| e.to_string())?
.as_str(),
settings.network_connect_timeout, settings.network_connect_timeout,
settings.network_timeout, settings.network_timeout,
) )
@ -139,6 +175,8 @@ fn main() {
home_info, home_info,
config, config,
session, session,
login,
register,
dotfiles::aliases::import_aliases, dotfiles::aliases::import_aliases,
dotfiles::aliases::delete_alias, dotfiles::aliases::delete_alias,
dotfiles::aliases::set_alias, dotfiles::aliases::set_alias,

View File

@ -12,6 +12,7 @@
"dependencies": { "dependencies": {
"@headlessui/react": "^1.7.18", "@headlessui/react": "^1.7.18",
"@heroicons/react": "^2.1.3", "@heroicons/react": "^2.1.3",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-slot": "^1.0.2",
"@tailwindcss/forms": "^0.5.7", "@tailwindcss/forms": "^0.5.7",

View File

@ -11,6 +11,9 @@ dependencies:
'@heroicons/react': '@heroicons/react':
specifier: ^2.1.3 specifier: ^2.1.3
version: 2.1.3(react@18.2.0) version: 2.1.3(react@18.2.0)
'@radix-ui/react-dialog':
specifier: ^1.0.5
version: 1.0.5(@types/react-dom@18.2.24)(@types/react@18.2.74)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-dropdown-menu': '@radix-ui/react-dropdown-menu':
specifier: ^2.0.6 specifier: ^2.0.6
version: 2.0.6(@types/react-dom@18.2.24)(@types/react@18.2.74)(react-dom@18.2.0)(react@18.2.0) version: 2.0.6(@types/react-dom@18.2.24)(@types/react@18.2.74)(react-dom@18.2.0)(react@18.2.0)

View File

@ -1,6 +1,19 @@
import "./App.css"; import "./App.css";
import { useState, ReactElement } from "react"; import { useState, ReactElement } from "react";
import { useStore } from "@/state/store";
import Button, { ButtonStyle } from "@/components/Button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { import {
Cog6ToothIcon, Cog6ToothIcon,
HomeIcon, HomeIcon,
@ -16,6 +29,7 @@ function classNames(...classes: any) {
import Home from "./pages/Home.tsx"; import Home from "./pages/Home.tsx";
import History from "./pages/History.tsx"; import History from "./pages/History.tsx";
import Dotfiles from "./pages/Dotfiles.tsx"; import Dotfiles from "./pages/Dotfiles.tsx";
import LoginOrRegister from "./components/LoginOrRegister.tsx";
enum Section { enum Section {
Home, Home,
@ -39,6 +53,8 @@ function App() {
// I think hashrouter may work, but I'd rather avoiding thinking of them as // I think hashrouter may work, but I'd rather avoiding thinking of them as
// pages // pages
const [section, setSection] = useState(Section.Home); const [section, setSection] = useState(Section.Home);
const user = useStore((state) => state.user);
console.log(user);
const navigation = [ const navigation = [
{ {
@ -96,16 +112,19 @@ function App() {
</ul> </ul>
</li> </li>
<li className="mt-auto"> <li className="mt-auto">
<a {user && !user.isLoggedIn() && (
href="#" <Dialog>
className="group -mx-2 flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:bg-gray-50 hover:text-green-600" <DialogTrigger className="w-full">
> <Button
<Cog6ToothIcon text={"Login or Register"}
className="h-6 w-6 shrink-0 text-gray-400 group-hover:text-green-600" style={ButtonStyle.PrimarySmFill}
aria-hidden="true" />
/> </DialogTrigger>
Settings <DialogContent>
</a> <LoginOrRegister />
</DialogContent>
</Dialog>
)}
</li> </li>
</ul> </ul>
</nav> </nav>

View File

@ -0,0 +1,20 @@
export enum ButtonStyle {
PrimarySm = "bg-emerald-500 hover:bg-emerald-600",
PrimarySmFill = "bg-emerald-500 hover:bg-emerald-600 w-full text-sm",
}
interface ButtonProps {
text: string;
style: ButtonStyle;
}
export default function Button(props: ButtonProps) {
return (
<button
type="button"
className={`rounded ${props.style} px-2 py-1 font-semibold text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500`}
>
{props.text}
</button>
);
}

View File

@ -0,0 +1,341 @@
import Logo from "@/assets/logo-light.svg";
import { useState } from "react";
import { login, register } from "@/state/client";
import { useStore } from "@/state/store";
interface LoginProps {
toggleRegister: () => void;
}
function Login(props: LoginProps) {
const refreshUser = useStore((state) => state.refreshUser);
const [errors, setErrors] = useState<string | null>(null);
const doLogin = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const username = form.username.value;
const password = form.password.value;
const key = form.key.value;
console.log("Logging in...");
try {
await login(username, password, key);
refreshUser();
console.log("Logged in");
} catch (e) {
console.error(e);
setErrors(e);
}
};
return (
<>
<div className="flex min-h-full flex-1 flex-col justify-center px-6 ">
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
<img className="mx-auto h-10 w-auto" src={Logo} alt="Atuin" />
<h2 className="mt-5 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">
Sign in to your account
</h2>
<p className="text-sm text-center text-gray-600 mt-4 text-wrap">
Backup and sync your data across devices. All data is end-to-end
encrypted and stored securely in the cloud.
</p>
</div>
<div className="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
<form
className="space-y-6"
action="#"
method="POST"
onSubmit={doLogin}
>
<div>
<label
htmlFor="username"
className="block text-sm font-medium leading-6 text-gray-900"
>
Username
</label>
<div className="mt-2">
<input
id="username"
name="username"
type="username"
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
spellCheck="false"
required
className="block w-full rounded-md border-0 px-1.5 py-1.5 outline-none text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-emerald-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div>
<div className="flex items-center justify-between">
<label
htmlFor="password"
className="block text-sm font-medium leading-6 text-gray-900"
>
Password
</label>
<div className="text-sm">
{/* You can't right now. Sorry. Validate emails first.
<a
href="#"
className="font-semibold text-emerald-600 hover:text-emerald-500"
>
Forgot password?
</a>
*/}
</div>
</div>
<div className="mt-2">
<input
id="password"
name="password"
type="password"
autoCapitalize="off"
autoCorrect="off"
spellCheck="false"
autoComplete="current-password"
required
className="block w-full rounded-md border-0 px-1.5 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset outline-none focus:ring-emerald-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div>
<div className="flex items-center justify-between">
<label
htmlFor="key"
className="block text-sm font-medium leading-6 text-gray-900"
>
<p>Key</p>
<p className="text-xs text-gray-500 font-normal">
Paste the output of "atuin key" from another machine
</p>
</label>
</div>
<div className="mt-2">
<input
id="key"
name="key"
autoCapitalize="off"
autoCorrect="off"
spellCheck="false"
autoComplete="off"
required
className="block w-full rounded-md border-0 px-1.5 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset outline-none focus:ring-emerald-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div>
<button
type="submit"
className="flex w-full justify-center rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-emerald-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600"
>
Sign in
</button>
</div>
</form>
{errors && (
<p className="mt-4 text-center text-sm text-red-500">{errors}</p>
)}
<p className="mt-10 text-center text-sm text-gray-500">
Not a member?{" "}
<a
href="#"
className="font-semibold leading-6 text-emerald-600 hover:text-emerald-500"
onClick={(e) => {
e.preventDefault();
props.toggleRegister();
}}
>
Register
</a>
</p>
</div>
</div>
</>
);
}
interface RegisterProps {
toggleLogin: () => void;
}
function Register(props: RegisterProps) {
const refreshUser = useStore((state) => state.refreshUser);
const [errors, setErrors] = useState<string | null>(null);
const doRegister = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const username = form.username.value;
const email = form.email.value;
const password = form.password.value;
console.log("Logging in...");
try {
await register(username, email, password);
refreshUser();
console.log("Logged in");
} catch (e) {
console.error(e);
setErrors(e);
}
};
return (
<>
<div className="flex min-h-full flex-1 flex-col justify-center px-6 ">
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
<img className="mx-auto h-10 w-auto" src={Logo} alt="Atuin" />
<h2 className="mt-5 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">
Register for an account
</h2>
<p className="text-sm text-center text-gray-600 mt-4 text-wrap">
Backup and sync your data across devices. All data is end-to-end
encrypted and stored securely in the cloud.
</p>
</div>
<div className="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
<form
className="space-y-6"
action="#"
method="POST"
onSubmit={doRegister}
>
<div>
<label
htmlFor="username"
className="block text-sm font-medium leading-6 text-gray-900"
>
Username
</label>
<div className="mt-2">
<input
id="username"
name="username"
type="username"
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
spellCheck="false"
required
className="block w-full rounded-md border-0 px-1.5 py-1.5 outline-none text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-emerald-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div>
<label
htmlFor="email"
className="block text-sm font-medium leading-6 text-gray-900"
>
Email
</label>
<div className="mt-2">
<input
id="email"
name="email"
type="email"
autoComplete="email"
autoCapitalize="off"
autoCorrect="off"
spellCheck="false"
required
className="block w-full rounded-md border-0 px-1.5 py-1.5 outline-none text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-emerald-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div>
<div className="flex items-center justify-between">
<label
htmlFor="password"
className="block text-sm font-medium leading-6 text-gray-900"
>
Password
</label>
<div className="text-sm">
{/* You can't right now. Sorry. Validate emails first.
<a
href="#"
className="font-semibold text-emerald-600 hover:text-emerald-500"
>
Forgot password?
</a>
*/}
</div>
</div>
<div className="mt-2">
<input
id="password"
name="password"
type="password"
autoCapitalize="off"
autoCorrect="off"
spellCheck="false"
autoComplete="current-password"
required
className="block w-full rounded-md border-0 px-1.5 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset outline-none focus:ring-emerald-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div>
<button
type="submit"
className="flex w-full justify-center rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-emerald-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600"
>
Register
</button>
</div>
</form>
{errors && (
<p className="mt-4 text-center text-sm text-red-500">{errors}</p>
)}
<p className="mt-10 text-center text-sm text-gray-500">
Already have an account?{" "}
<a
href="#"
className="font-semibold leading-6 text-emerald-600 hover:text-emerald-500"
onClick={(e) => {
e.preventDefault();
props.toggleLogin();
}}
>
Login
</a>
</p>
</div>
</div>
</>
);
}
export default function LoginOrRegister() {
let [login, setLogin] = useState<boolean>(false);
if (login) {
return <Login toggleRegister={() => setLogin(false)} />;
}
return <Register toggleLogin={() => setLogin(true)} />;
}

View File

@ -13,8 +13,13 @@ import {
function renderLoading() { function renderLoading() {
return ( return (
<div className="flex items-center justify-center h-full"> <div className="flex flex-col items-center justify-center h-full ">
<PacmanLoader color="#26bd65" /> <div>
<PacmanLoader color="#26bd65" />
</div>
<div className="block mt-4">
<p>Crunching the latest numbers...</p>
</div>
</div> </div>
); );
} }

View File

@ -0,0 +1,120 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@ -11,3 +11,19 @@ export async function sessionToken(): Promise<String> {
export async function settings(): Promise<Settings> { export async function settings(): Promise<Settings> {
return await invoke("config"); return await invoke("config");
} }
export async function login(
username: string,
password: string,
key: string,
): Promise<string> {
return await invoke("login", { username, password, key });
}
export async function register(
username: string,
email: string,
password: string,
): Promise<string> {
return await invoke("register", { username, email, password });
}

View File

@ -1,12 +1,18 @@
import Database from "@tauri-apps/plugin-sql"; import Database from "@tauri-apps/plugin-sql";
export interface User { export class User {
username: string; username: string | null;
constructor(username: string) {
this.username = username;
}
isLoggedIn(): boolean {
return this.username !== "" && this.username !== null;
}
} }
export const DefaultUser: User = { export const DefaultUser: User = new User("");
username: "",
};
export interface HomeInfo { export interface HomeInfo {
historyCount: number; historyCount: number;

View File

@ -94,6 +94,7 @@ export const useStore = create<AtuinState>()((set, get) => ({
session = await sessionToken(); session = await sessionToken();
} catch (e) { } catch (e) {
console.log("Not logged in, so not refreshing user"); console.log("Not logged in, so not refreshing user");
set({ user: DefaultUser });
return; return;
} }
let url = config.sync_address + "/api/v0/me"; let url = config.sync_address + "/api/v0/me";
@ -105,7 +106,7 @@ export const useStore = create<AtuinState>()((set, get) => ({
}); });
let me = await res.json(); let me = await res.json();
set({ user: me }); set({ user: new User(me.username) });
}, },
historyNextPage: (query?: string) => { historyNextPage: (query?: string) => {