├── .gitignore ├── .taurignore ├── Cargo.toml ├── LICENSE ├── README.md ├── Tauri.toml ├── assets ├── 128x128.png ├── 128x128@2x.png ├── 32x32.png ├── CascadiaMono.woff2 ├── crypto-architecture.png ├── icon.icns ├── icon.ico ├── icon.svg └── login-page.png ├── build.rs ├── package.json ├── src ├── cryptography.rs ├── database.rs ├── error.rs ├── logs.rs └── main.rs ├── tsconfig.json ├── ui ├── AddPage.tsx ├── LoginPage.tsx ├── SignUpPage.tsx ├── StartPage.tsx ├── backend.tsx ├── index.html ├── main.tsx └── utils.tsx └── vite.config.ts /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | target/ 3 | Cargo.lock 4 | 5 | # These are backup files generated by rustfmt 6 | **/*.rs.bk 7 | 8 | # vite build dir 9 | build/ 10 | 11 | # js 12 | node_modules/ 13 | package-lock.json 14 | -------------------------------------------------------------------------------- /.taurignore: -------------------------------------------------------------------------------- 1 | ui 2 | node_modules 3 | README.md 4 | .gitignore 5 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "pw-manager" 3 | version = "0.1.0" 4 | authors = ["Axel Lindeberg"] 5 | default-run = "pw-manager" 6 | edition = "2021" 7 | 8 | [dependencies] 9 | tauri = { version = "1.1", features = ["window-start-dragging"] } 10 | serde = { version = "1.0", features = ["derive"] } 11 | serde_json = "1.0" 12 | openssl = "0.10" 13 | once_cell = "1.13" 14 | arboard = "2.1" 15 | fern = "0.6" 16 | log = "0.4" 17 | chrono = "0.4" 18 | 19 | [dev-dependencies] 20 | tempfile = "3" 21 | 22 | [build-dependencies] 23 | tauri-build = { version = "1.1", features = ["config-toml"] } 24 | 25 | [features] 26 | default = [ "custom-protocol" ] 27 | custom-protocol = [ "tauri/custom-protocol" ] 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Axel Lindeberg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tauri-pw-manager (working title) 2 | A desktop password manager using Tauri, with a backend in Rust and a frontend in Typescript and React. 3 | 4 | ![login page](./assets/login-page.png) 5 | 6 | ## Cryptography architecture 7 | ![cryptography architecture](./assets/crypto-architecture.png) 8 | 9 | ## usage 10 | Assumes you have the [Rust toolchain](https://rustup.rs/) and [npm](https://www.npmjs.com/) installed. 11 | 12 | ```bash 13 | npm run setup # install dependencies 14 | cargo tauri dev # run the app in development mode 15 | cargo tauri build # build a release version of the app 16 | ``` 17 | -------------------------------------------------------------------------------- /Tauri.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | beforeDevCommand = "npm run --silent dev" 3 | devPath = "http://127.0.0.1:3000" 4 | beforeBuildCommand = "npm run --silent build" 5 | distDir = "./ui/build" 6 | 7 | [package] 8 | productName = "pw-manager" 9 | version = "0.1.0" 10 | 11 | [tauri.allowlist.window] 12 | startDragging = true 13 | 14 | [[tauri.windows]] 15 | title = "tauri pw manager" 16 | width = 800 17 | height = 600 18 | decorations = false 19 | transparent = true 20 | 21 | [tauri.bundle] 22 | identifier = "tauri.pw.manager" 23 | targets = "all" 24 | icon = [ 25 | "assets/32x32.png", 26 | "assets/128x128.png", 27 | "assets/128x128@2x.png", 28 | "assets/icon.icns", 29 | "assets/icon.ico" 30 | ] 31 | -------------------------------------------------------------------------------- /assets/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AxlLind/tauri-pw-manager/763d20ee6c70add66778228de2d71744f16236f3/assets/128x128.png -------------------------------------------------------------------------------- /assets/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AxlLind/tauri-pw-manager/763d20ee6c70add66778228de2d71744f16236f3/assets/128x128@2x.png -------------------------------------------------------------------------------- /assets/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AxlLind/tauri-pw-manager/763d20ee6c70add66778228de2d71744f16236f3/assets/32x32.png -------------------------------------------------------------------------------- /assets/CascadiaMono.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AxlLind/tauri-pw-manager/763d20ee6c70add66778228de2d71744f16236f3/assets/CascadiaMono.woff2 -------------------------------------------------------------------------------- /assets/crypto-architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AxlLind/tauri-pw-manager/763d20ee6c70add66778228de2d71744f16236f3/assets/crypto-architecture.png -------------------------------------------------------------------------------- /assets/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AxlLind/tauri-pw-manager/763d20ee6c70add66778228de2d71744f16236f3/assets/icon.icns -------------------------------------------------------------------------------- /assets/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AxlLind/tauri-pw-manager/763d20ee6c70add66778228de2d71744f16236f3/assets/icon.ico -------------------------------------------------------------------------------- /assets/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/login-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AxlLind/tauri-pw-manager/763d20ee6c70add66778228de2d71744f16236f3/assets/login-page.png -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "setup": "cargo install tauri-cli && npm install", 5 | "dev": "vite", 6 | "build": "tsc && vite build" 7 | }, 8 | "dependencies": { 9 | "@emotion/react": "^11.9.3", 10 | "@emotion/styled": "^11.9.3", 11 | "@mui/icons-material": "^5.8.4", 12 | "@mui/material": "^5.8.7", 13 | "@tauri-apps/api": "^1.0.2", 14 | "react": "^18.0.0", 15 | "react-dom": "^18.0.0" 16 | }, 17 | "devDependencies": { 18 | "@types/react": "^18.0.0", 19 | "@types/react-dom": "^18.0.0", 20 | "@vitejs/plugin-react": "^1.3.0", 21 | "typescript": "^4.6.3", 22 | "vite": "^2.9.9" 23 | }, 24 | "volta": { 25 | "node": "18.5.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/cryptography.rs: -------------------------------------------------------------------------------- 1 | use std::marker::PhantomData; 2 | use openssl::error::ErrorStack; 3 | use openssl::hash::MessageDigest; 4 | use openssl::rand::rand_bytes; 5 | use openssl::symm::{Cipher, encrypt_aead, decrypt_aead, encrypt, decrypt}; 6 | use serde::{Serialize, de::DeserializeOwned}; 7 | use crate::error::Error; 8 | 9 | const AAD_MESSAGE: &[u8] = b"Tauri PW Manager v0.0.1"; 10 | 11 | #[derive(Default)] 12 | pub struct EncryptedBlob { 13 | iv: [u8; 12], 14 | tag: [u8; 16], 15 | data: Vec, 16 | _t: PhantomData, 17 | } 18 | 19 | impl EncryptedBlob { 20 | pub fn encrypt(t: &T, key: &[u8]) -> Result { 21 | let iv = random_bytes::<12>(); 22 | let mut tag = [0; 16]; 23 | let serialized = serde_json::to_vec(&t)?; 24 | let data = encrypt_aead(Cipher::aes_256_gcm(), key, Some(&iv), AAD_MESSAGE, &serialized, &mut tag)?; 25 | Ok(Self { iv, tag, data, _t: PhantomData }) 26 | } 27 | 28 | pub fn decrypt(&self, key: &[u8]) -> Result { 29 | let bytes = decrypt_aead(Cipher::aes_256_gcm(), key, Some(&self.iv), AAD_MESSAGE, &self.data, &self.tag)?; 30 | let t = serde_json::from_slice(&bytes)?; 31 | Ok(t) 32 | } 33 | 34 | pub fn from_bytes(bytes: &[u8]) -> Result { 35 | if bytes.len() < (12 + 16 + 1) { 36 | return Err(Error::InvalidDatabase); 37 | } 38 | Ok(Self { 39 | iv: bytes[0..12].try_into().unwrap(), 40 | tag: bytes[12..12+16].try_into().unwrap(), 41 | data: bytes[12+16..].to_vec(), 42 | _t: PhantomData, 43 | }) 44 | } 45 | 46 | pub fn bytes(&self) -> impl Iterator + '_ { 47 | self.iv.iter().chain(self.tag.iter()).chain(self.data.iter()).copied() 48 | } 49 | } 50 | 51 | pub fn random_bytes() -> [u8; SIZE] { 52 | let mut bytes = [0; SIZE]; 53 | rand_bytes(&mut bytes).expect("failed to generate random bytes"); 54 | bytes 55 | } 56 | 57 | pub fn pbkdf2_hmac(password: &[u8], salt: &[u8]) -> [u8; 32] { 58 | let mut key = [0; 32]; 59 | openssl::pkcs5::pbkdf2_hmac(password, salt, 100_000, MessageDigest::sha256(), &mut key).expect("pbkdf2 should not fail"); 60 | key 61 | } 62 | 63 | pub fn encrypt_key(master_key: &[u8], key: &[u8]) -> Result<([u8; 32], [u8; 16]), ErrorStack> { 64 | let nonce = random_bytes::<16>(); 65 | let ciphertext = encrypt(Cipher::aes_256_ctr(), master_key, Some(&nonce), key)?; 66 | Ok((ciphertext.try_into().unwrap(), nonce)) 67 | } 68 | 69 | pub fn decrypt_key(master_key: &[u8], encrypted_key: &[u8], nonce: &[u8]) -> Result<[u8; 32], ErrorStack> { 70 | let plaintext = decrypt(Cipher::aes_256_ctr(), master_key, Some(nonce), encrypted_key)?; 71 | Ok(plaintext.try_into().unwrap()) 72 | } 73 | 74 | pub fn generate_password(alphabet: &[u8], len: usize) -> String { 75 | assert!(alphabet.len() < 256); 76 | let mod_ceil = alphabet.len().next_power_of_two(); 77 | (0..len).map(|_| loop { 78 | let [b] = random_bytes::<1>(); 79 | if let Some(&c) = alphabet.get(b as usize % mod_ceil) { 80 | return c as char; 81 | } 82 | }).collect() 83 | } 84 | 85 | #[cfg(test)] 86 | mod tests { 87 | use super::*; 88 | 89 | #[test] 90 | fn test_encrypt_decrypt_lob() { 91 | let data = "This is some serious data right here".to_string(); 92 | let key = random_bytes::<32>(); 93 | let blob1 = EncryptedBlob::encrypt(&data, &key).expect("this should encrypt"); 94 | let blob2 = EncryptedBlob::encrypt(&data, &key).expect("this should encrypt"); 95 | 96 | // encrypted with two random ivs 97 | assert_ne!(blob1.iv, blob2.iv); 98 | assert_ne!(blob1.data, blob2.data); 99 | assert_ne!(blob1.tag, blob2.tag); 100 | 101 | let decrypted_data1 = blob1.decrypt(&key).expect("this should decrypt"); 102 | let decrypted_data2 = blob2.decrypt(&key).expect("this should decrypt"); 103 | assert_eq!(decrypted_data1, data); 104 | assert_eq!(decrypted_data2, data); 105 | 106 | // a single bit flip should mean failure 107 | let mut blob = EncryptedBlob::encrypt(&data, &key).expect("this should encrypt"); 108 | blob.data[4] += 1; 109 | assert!(blob.decrypt(&key).is_err()); 110 | } 111 | 112 | #[test] 113 | fn test_blob_to_from_vec() { 114 | let bytes = random_bytes::<128>().to_vec(); 115 | let blob = EncryptedBlob::>::from_bytes(&bytes).expect("should be convertable"); 116 | assert_eq!(bytes, blob.bytes().collect::>()); 117 | 118 | let too_few_bytes = [0; 28]; 119 | assert!(EncryptedBlob::>::from_bytes(&too_few_bytes).is_err()); 120 | } 121 | 122 | #[test] 123 | fn test_generate_password() { 124 | assert_eq!(generate_password(b"a", 5), "aaaaa"); 125 | 126 | const ASCII_PRINTABLE: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!\"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~"; 127 | let pw = generate_password(ASCII_PRINTABLE, 2000); 128 | for c in pw.bytes() { 129 | assert!(ASCII_PRINTABLE.contains(&c)); 130 | } 131 | assert_eq!(pw.len(), 2000); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/database.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use serde::{Serialize, Deserialize}; 3 | 4 | #[derive(Default, Hash, Serialize, Deserialize, Clone)] 5 | pub struct Credential { 6 | pub username: String, 7 | pub password: String, 8 | } 9 | 10 | #[derive(Default, Serialize, Deserialize)] 11 | pub struct CredentialsDatabase { 12 | username: String, 13 | credentials: HashMap 14 | } 15 | 16 | impl CredentialsDatabase { 17 | pub fn new(username: String) -> Self { 18 | Self { username, ..Self::default() } 19 | } 20 | 21 | pub fn username(&self) -> &str { &self.username } 22 | 23 | pub fn add(&mut self, name: String, username: String, password: String) { 24 | self.credentials.insert(name, Credential { username, password }); 25 | } 26 | 27 | pub fn remove(&mut self, name: &str) -> bool { 28 | self.credentials.remove(name).is_some() 29 | } 30 | 31 | pub fn entry(&self, name: &str) -> Option<&Credential> { 32 | self.credentials.get(name) 33 | } 34 | 35 | pub fn entries(&self) -> impl Iterator { 36 | self.credentials.iter() 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use serde::{Serialize, ser::SerializeMap}; 2 | use crate::logs; 3 | 4 | #[derive(Debug, Clone, Copy)] 5 | pub enum Error { 6 | InvalidCredentials, 7 | InvalidDatabase, 8 | InvalidParameter, 9 | UsernameTaken, 10 | Unexpected, 11 | } 12 | 13 | impl Error { 14 | fn message(&self) -> &'static str { 15 | match self { 16 | Self::InvalidCredentials => "invalid credentials", 17 | Self::InvalidDatabase => "corrupt key database", 18 | Self::InvalidParameter => "invalid parameter", 19 | Self::UsernameTaken => "username already registered", 20 | Self::Unexpected => "unexpected error occurred" 21 | } 22 | } 23 | 24 | fn key(&self) -> &'static str { 25 | match self { 26 | Self::InvalidCredentials => "invalid_credentials", 27 | Self::InvalidDatabase => "invalid_database", 28 | Self::InvalidParameter => "invalid_parameter", 29 | Self::UsernameTaken => "username_taken", 30 | Self::Unexpected => "unexpected", 31 | } 32 | } 33 | } 34 | 35 | impl From for Error { 36 | fn from(e: T) -> Self { 37 | logs::error!("Unexpected error", e); 38 | Self::Unexpected 39 | } 40 | } 41 | 42 | impl Serialize for Error { 43 | fn serialize(&self, serializer: S) -> Result { 44 | let mut json_err = serializer.serialize_map(Some(2))?; 45 | json_err.serialize_entry("key", self.key())?; 46 | json_err.serialize_entry("error", self.message())?; 47 | json_err.end() 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/logs.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | use std::path::Path; 3 | use log::LevelFilter; 4 | use chrono::{Duration, Local, NaiveDate}; 5 | use crate::error::Error; 6 | 7 | pub fn initialize(logs_folder: &Path) -> Result<(), Error> { 8 | if !logs_folder.exists() { 9 | fs::create_dir(&logs_folder)?; 10 | } 11 | let log_file = Local::now().format("%Y-%m-%d.log").to_string(); 12 | fern::Dispatch::new() 13 | .format(|out, message, record| { 14 | out.finish(format_args!( 15 | "[{}][{}] {}", 16 | Local::now().format("%Y-%m-%d %H:%M:%S"), 17 | record.level(), 18 | message, 19 | )); 20 | }) 21 | .level(if cfg!(debug_assertions) { log::LevelFilter::Debug } else { LevelFilter::Info }) 22 | .chain(std::io::stdout()) 23 | .chain(fern::log_file(logs_folder.join(log_file))?) 24 | .apply()?; 25 | Ok(()) 26 | } 27 | 28 | pub fn remove_old(log_folder: &Path) -> Result<(), Error> { 29 | let deletion_point = Local::now().naive_local() - Duration::days(3); 30 | for e in fs::read_dir(log_folder)? { 31 | let path = e?.path(); 32 | if !path.is_file() || path.extension().map_or(false, |e| e != "log") { 33 | continue; 34 | } 35 | let file_stem = path.file_stem().unwrap().to_string_lossy(); 36 | let log_time = match NaiveDate::parse_from_str(&file_stem, "%Y-%m-%d") { 37 | Ok(d) => d.and_hms_opt(0, 0, 0).unwrap(), 38 | Err(_) => continue, 39 | }; 40 | if log_time < deletion_point { 41 | info!("removing old log file", file=path.file_name().unwrap().to_string_lossy()); 42 | fs::remove_file(path)?; 43 | } 44 | } 45 | Ok(()) 46 | } 47 | 48 | #[doc(hidden)] 49 | macro_rules! __log_arguments { 50 | // Parse a `key=value` argument, into `"key={}", value` 51 | ($level:expr, $prefix:literal, $fmt:expr $(, $args:expr)* ; $k:ident=$v:expr, $($tokens:tt)*) => { 52 | $crate::logs::__log_arguments!($level, ", ", concat!($fmt, $prefix, stringify!($k), "={}") $(, $args)*, $v ; $($tokens)*) 53 | }; 54 | // Parse a `key=?value` argument, into `"key={:?}", value` 55 | ($level:expr, $prefix:literal, $fmt:expr $(, $args:expr)* ; $k:ident=?$v:expr, $($tokens:tt)*) => { 56 | $crate::logs::__log_arguments!($level, ", ", concat!($fmt, $prefix, stringify!($k), "={:?}") $(, $args)*, $v ; $($tokens)*) 57 | }; 58 | // Parse a `key` argument, into `"key={}", key` 59 | ($level:expr, $prefix:literal, $fmt:expr $(, $args:expr)* ; $k:ident, $($tokens:tt)*) => { 60 | $crate::logs::__log_arguments!($level, ", ", concat!($fmt, $prefix, stringify!($k), "={}") $(, $args)*, $k ; $($tokens)*) 61 | }; 62 | // Output the final log expression 63 | ($level:expr, $_:literal, $fmt:expr $(, $args:expr)* ; $(,)?) => { 64 | ::log::log!($level, $fmt, $($args),*) 65 | }; 66 | } 67 | 68 | #[doc(hidden)] 69 | macro_rules! __log_impl { 70 | ($level:expr, $msg:literal, $($tokens:tt)*) => { 71 | $crate::logs::__log_arguments!($level, ": ", $msg ; $($tokens)*) 72 | }; 73 | } 74 | 75 | macro_rules! debug { ($($tokens:tt)+) => { $crate::logs::__log_impl!(::log::Level::Debug, $($tokens)+,) } } 76 | macro_rules! info { ($($tokens:tt)+) => { $crate::logs::__log_impl!(::log::Level::Info, $($tokens)+,) } } 77 | macro_rules! _warn { ($($tokens:tt)+) => { $crate::logs::__log_impl!(::log::Level::Warn, $($tokens)+,) } } 78 | macro_rules! error { ($($tokens:tt)+) => { $crate::logs::__log_impl!(::log::Level::Error, $($tokens)+,) } } 79 | 80 | pub(crate) use { __log_impl, __log_arguments }; 81 | #[allow(unused)] pub(crate) use { debug, info, _warn as warn, error }; 82 | 83 | #[cfg(test)] 84 | mod tests { 85 | use super::*; 86 | use tempfile::tempdir; 87 | 88 | #[test] 89 | fn test_remove_old_deletion_dates() -> std::io::Result<()> { 90 | let dir = tempdir()?; 91 | let now = Local::now().naive_local(); 92 | for days_old in 0..5 { 93 | let log = dir.path().join((now - Duration::days(days_old)).format("%Y-%m-%d.log").to_string()); 94 | fs::write(&log, "unittest")?; 95 | } 96 | assert_eq!(fs::read_dir(dir.path())?.count(), 5); 97 | remove_old(dir.path()).expect("remove_old should not fail"); 98 | assert_eq!(fs::read_dir(dir.path())?.count(), 3); 99 | Ok(()) 100 | } 101 | 102 | #[test] 103 | fn test_remove_old_invalid_files() -> std::io::Result<()> { 104 | let dir = tempdir()?; 105 | fs::write(dir.path().join("random_name.log"), "unittest")?; 106 | fs::write(dir.path().join("2000-99-99.log"), "unittest")?; 107 | fs::write(dir.path().join("1984-01-01.txt"), "unittest")?; 108 | fs::write(dir.path().join("1984-01-01.txt.log"), "unittest")?; 109 | fs::create_dir(dir.path().join("nested_dir"))?; 110 | assert_eq!(fs::read_dir(dir.path())?.count(), 5); 111 | remove_old(dir.path()).expect("remove_old should not fail"); 112 | assert_eq!(fs::read_dir(dir.path())?.count(), 5); 113 | Ok(()) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![forbid(unsafe_code)] 2 | #![cfg_attr(all(not(debug_assertions), target_os = "windows"), windows_subsystem = "windows")] 3 | mod database; 4 | mod cryptography; 5 | mod error; 6 | mod logs; 7 | 8 | use std::fs; 9 | use std::path::PathBuf; 10 | use std::sync::Mutex; 11 | use database::Credential; 12 | use once_cell::sync::Lazy; 13 | use arboard::Clipboard; 14 | use tauri::State; 15 | use crate::cryptography::EncryptedBlob; 16 | use crate::database::CredentialsDatabase; 17 | use crate::error::Error; 18 | 19 | pub static APP_FOLDER: Lazy = Lazy::new(|| { 20 | if cfg!(target_os = "windows") { 21 | let appdata = std::env::var("APPDATA").expect("$APPDATA not set!"); 22 | [&appdata, "tauri-pw-manager"].iter().collect() 23 | } else { 24 | let home = std::env::var("HOME").expect("$HOME not set!"); 25 | [&home, ".config", "tauri-pw-manager"].iter().collect() 26 | } 27 | }); 28 | 29 | #[derive(Default)] 30 | struct UserSession { 31 | file: PathBuf, 32 | nonce: [u8; 16], 33 | encrypted_key: [u8; 32], 34 | key: [u8; 32], 35 | db: CredentialsDatabase, 36 | } 37 | 38 | fn save_database(session: &UserSession) -> Result<(), Error> { 39 | let encrypted_blob = EncryptedBlob::encrypt(&session.db, &session.key)?; 40 | let file_content = session.nonce.iter() 41 | .copied() 42 | .chain(session.encrypted_key) 43 | .chain(encrypted_blob.bytes()) 44 | .collect::>(); 45 | fs::write(&session.file, &file_content)?; 46 | Ok(()) 47 | } 48 | 49 | #[tauri::command] 50 | fn window_close(window: tauri::Window) -> Result<(), Error> { 51 | logs::debug!("Closing window"); 52 | window.close().map_err(|_| Error::Unexpected) 53 | } 54 | 55 | #[tauri::command] 56 | fn window_minimize(window: tauri::Window) -> Result<(), Error> { 57 | logs::debug!("Minimizing window"); 58 | window.minimize().map_err(|_| Error::Unexpected) 59 | } 60 | 61 | #[tauri::command] 62 | fn window_toggle_fullscreen(window: tauri::Window) -> Result<(), Error> { 63 | let fullscreen = window.is_fullscreen()?; 64 | logs::debug!("Toggling window fullscreen", fullscreen); 65 | window.set_fullscreen(!fullscreen)?; 66 | Ok(()) 67 | } 68 | 69 | #[tauri::command] 70 | fn copy_to_clipboard(name: String, thing: String, session_mutex: State<'_, Mutex>>) -> Result<(), Error> { 71 | logs::debug!("Copying to clipboard", name, thing); 72 | let mut session_guard = session_mutex.lock()?; 73 | let session = session_guard.as_mut().ok_or(Error::InvalidCredentials)?; 74 | let entry = session.db.entry(&name).ok_or(Error::InvalidParameter)?; 75 | let text = match thing.as_str() { 76 | "username" => &entry.username, 77 | "password" => &entry.password, 78 | _ => return Err(Error::InvalidParameter), 79 | }; 80 | Clipboard::new()?.set_text(text.clone())?; 81 | Ok(()) 82 | } 83 | 84 | #[tauri::command] 85 | fn generate_password(length: usize, types: Vec) -> Result { 86 | logs::debug!("Generating password", types=?types); 87 | if types.is_empty() { 88 | return Err(Error::InvalidParameter); 89 | } 90 | if !(10..=128).contains(&length) { 91 | return Err(Error::InvalidParameter); 92 | } 93 | let alphabet = types.iter() 94 | .map(|t| match t.as_str() { 95 | "lowercase" => Ok("abcdefghijklmnopqrstuvwxyz"), 96 | "uppercase" => Ok("ABCDEFGHIJKLMNOPQRSTUVWXYZ"), 97 | "digits" => Ok("0123456789"), 98 | "special" => Ok("!@#$%^&*"), 99 | _ => Err(Error::InvalidParameter), 100 | }) 101 | .collect::,_>>()? 102 | .join(""); 103 | Ok(cryptography::generate_password(alphabet.as_bytes(), length)) 104 | } 105 | 106 | #[tauri::command] 107 | fn get_credentials_info(name: String, session_mutex: State<'_, Mutex>>) -> Result { 108 | logs::debug!("Fetching credentials info", name); 109 | let session_guard = session_mutex.lock()?; 110 | let session = session_guard.as_ref().ok_or(Error::InvalidCredentials)?; 111 | session.db.entry(&name).cloned().ok_or(Error::InvalidParameter) 112 | } 113 | 114 | #[tauri::command] 115 | fn fetch_credentials(session_mutex: State<'_, Mutex>>) -> Result, Error> { 116 | logs::debug!("Fetching credentials"); 117 | let session_guard = session_mutex.lock()?; 118 | let session = session_guard.as_ref().ok_or(Error::InvalidCredentials)?; 119 | Ok(session.db.entries().map(|(k,_)| k.clone()).collect()) 120 | } 121 | 122 | #[tauri::command] 123 | fn remove_credentials(name: String, session_mutex: State<'_, Mutex>>) -> Result<(), Error> { 124 | logs::info!("Removing credentials", name); 125 | let mut session_guard = session_mutex.lock()?; 126 | let session = session_guard.as_mut().ok_or(Error::InvalidCredentials)?; 127 | if !session.db.remove(&name) { 128 | return Err(Error::InvalidParameter); 129 | } 130 | save_database(session)?; 131 | Ok(()) 132 | } 133 | 134 | #[tauri::command] 135 | fn add_credentials(name: String, username: String, password: String, session_mutex: State<'_, Mutex>>) -> Result<(), Error> { 136 | logs::info!("Adding credential", name); 137 | if name.is_empty() || username.is_empty() || password.is_empty() { 138 | return Err(Error::InvalidCredentials); 139 | } 140 | let mut session_guard = session_mutex.lock()?; 141 | let session = session_guard.as_mut().ok_or(Error::InvalidCredentials)?; 142 | session.db.add(name, username, password); 143 | save_database(session)?; 144 | Ok(()) 145 | } 146 | 147 | #[tauri::command] 148 | fn logout(session: State<'_, Mutex>>) -> Result<(), Error> { 149 | let mut session = session.lock()?; 150 | logs::info!("Logging out", logged_in=session.is_some()); 151 | *session = None; 152 | Ok(()) 153 | } 154 | 155 | #[tauri::command] 156 | fn login(username: String, password: String, session: State<'_, Mutex>>) -> Result<(), Error> { 157 | logs::info!("Logging in", username); 158 | if username.is_empty() || password.is_empty() { 159 | return Err(Error::InvalidCredentials); 160 | } 161 | let mut session = session.lock()?; 162 | if session.is_some() { 163 | return Err(Error::Unexpected); 164 | } 165 | let file = APP_FOLDER.join(format!("{username}.pwdb")); 166 | if !file.exists() { 167 | return Err(Error::InvalidCredentials); 168 | } 169 | let file_contents = fs::read(&file)?; 170 | if file_contents.len() < 16+32+1 { 171 | return Err(Error::InvalidDatabase); 172 | } 173 | let nonce: [u8; 16] = file_contents[..16].try_into().unwrap(); 174 | let encrypted_key: [u8; 32] = file_contents[16..16+32].try_into().unwrap(); 175 | let master_key = cryptography::pbkdf2_hmac(password.as_bytes(), username.as_bytes()); 176 | let key = cryptography::decrypt_key(&master_key, &encrypted_key, &nonce).map_err(|_| Error::InvalidCredentials)?; 177 | let db: CredentialsDatabase = EncryptedBlob::from_bytes(&file_contents[16+32..])? 178 | .decrypt(&key) 179 | .map_err(|_| Error::InvalidCredentials)?; 180 | if db.username() != username { 181 | return Err(Error::InvalidDatabase); 182 | } 183 | *session = Some(UserSession { file, nonce, encrypted_key, key, db }); 184 | Ok(()) 185 | } 186 | 187 | #[tauri::command] 188 | fn create_account(username: String, password: String, session: State<'_, Mutex>>) -> Result<(), Error> { 189 | logs::info!("Creating account", username); 190 | if username.is_empty() || password.is_empty() { 191 | return Err(Error::InvalidCredentials); 192 | } 193 | let mut session = session.lock()?; 194 | if session.is_some() { 195 | return Err(Error::Unexpected); 196 | } 197 | let file = APP_FOLDER.join(format!("{username}.pwdb")); 198 | if file.exists() { 199 | return Err(Error::UsernameTaken); 200 | } 201 | let master_key = cryptography::pbkdf2_hmac(password.as_bytes(), username.as_bytes()); 202 | let key = cryptography::random_bytes::<32>(); 203 | let (encrypted_key, nonce) = cryptography::encrypt_key(&master_key, &key)?; 204 | let db = CredentialsDatabase::new(username); 205 | *session = Some(UserSession { file, nonce, encrypted_key, key, db }); 206 | save_database(session.as_ref().unwrap())?; 207 | Ok(()) 208 | } 209 | 210 | fn main() { 211 | if !APP_FOLDER.exists() { 212 | fs::create_dir(&*APP_FOLDER).expect("failed to create app folder"); 213 | } 214 | 215 | logs::initialize(&APP_FOLDER.join("logs")).expect("failed to initialize logger"); 216 | logs::remove_old(&APP_FOLDER.join("logs")).expect("failed to remove old logs"); 217 | 218 | let context = tauri::generate_context!(); 219 | tauri::Builder::default() 220 | .manage(Mutex::>::default()) 221 | .invoke_handler(tauri::generate_handler![ 222 | create_account, 223 | login, 224 | logout, 225 | fetch_credentials, 226 | get_credentials_info, 227 | add_credentials, 228 | remove_credentials, 229 | generate_password, 230 | copy_to_clipboard, 231 | window_close, 232 | window_minimize, 233 | window_toggle_fullscreen, 234 | ]) 235 | .menu(if cfg!(target_os = "macos") { 236 | tauri::Menu::os_default(&context.package_info().name) 237 | } else { 238 | tauri::Menu::default() 239 | }) 240 | .run(context) 241 | .expect("error while running tauri application"); 242 | } 243 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": false, 4 | "allowSyntheticDefaultImports": true, 5 | "forceConsistentCasingInFileNames": true, 6 | "isolatedModules": true, 7 | "jsx": "react-jsx", 8 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 9 | "module": "ESNext", 10 | "moduleResolution": "Node", 11 | "noEmit": true, 12 | "noImplicitAny": true, 13 | "resolveJsonModule": true, 14 | "skipLibCheck": true, 15 | "strict": true, 16 | "target": "ESNext" 17 | }, 18 | "include": ["ui", "vite.config.ts"] 19 | } 20 | -------------------------------------------------------------------------------- /ui/AddPage.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useState } from 'react'; 2 | import { Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Paper, Slider, Stack, TextField, ToggleButton, ToggleButtonGroup, Tooltip, Typography } from '@mui/material'; 3 | import { Loop } from '@mui/icons-material'; 4 | import { PageContext, PasswordField, useAsyncEffect } from './utils'; 5 | import { add_credentials, generate_password } from './backend'; 6 | 7 | const pwCharColor = (c: string) => '!@#$%^&*'.includes(c) ? '#57c7ff' : '0123456789'.includes(c) ? '#ffbc58' : '#ffffff'; 8 | 9 | export function AddPage() { 10 | const { goToPage, showAlert } = useContext(PageContext); 11 | const [name, setName] = useState(''); 12 | const [username, setUsername] = useState(''); 13 | const [password, setPassword] = useState(''); 14 | const [openTooltip, setOpenToolip] = useState(false); 15 | const [openDialog, setOpenDialog] = useState(false); 16 | const [length, setLength] = useState(16); 17 | const [types, setTypes] = useState(['lowercase', 'uppercase', 'digits', 'special']); 18 | 19 | useAsyncEffect(async () => { 20 | if (!openDialog) return; 21 | const res = await generate_password(length, types); 22 | if (!res.ok) return showAlert(res.error); 23 | setPassword(res.value); 24 | }, [openDialog, length, types]); 25 | 26 | const onClickAddCredentials = async () => { 27 | if (name === '') return showAlert('Name missing.'); 28 | if (username === '') return showAlert('Username missing.'); 29 | if (password === '') return showAlert('Password missing.'); 30 | const res = await add_credentials(name, username, password); 31 | if (!res.ok) return showAlert(res.error); 32 | goToPage('start'); 33 | }; 34 | 35 | return <> 36 | 37 | Add Credentials 38 | !openDialog && e.key == 'Enter' && onClickAddCredentials()}> 39 | setName(e.target.value)}/> 40 | setUsername(e.target.value)}/> 41 | 42 | 43 | setOpenToolip(true)} onMouseLeave={() => setOpenToolip(false)} open={!openDialog && openTooltip}> 44 | } sx={{position: 'absolute', transform: 'translateY(8px)'}} onClick={() => setOpenDialog(true)}/> 45 | 46 | 47 | 48 | 49 | 50 | setOpenDialog(false)} onKeyDown={e => e.key == 'Enter' && setOpenDialog(false)}> 51 | Generate Password 52 | 53 | 54 | {[...password.length <= 39 ? password : `${password.substring(0, 39-3)}...`].map(c => {c})} 55 | 56 | setLength(value as number)}/> 57 | 58 | 59 | 60 | value.length && setTypes(value)}> 61 | {[['lowercase', 'a-z'], ['uppercase', 'A-Z'], ['digits', '0-9'], ['special', '!@#$%^&*']].map(([value, text]) => 62 | 63 | {text} 64 | 65 | )} 66 | 67 | 68 | 69 | 70 | 71 | ; 72 | } 73 | -------------------------------------------------------------------------------- /ui/LoginPage.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useState } from 'react'; 2 | import { Stack, Button, TextField, Typography, Box } from '@mui/material'; 3 | import { useAsyncEffect, PasswordField, AppIcon, PageContext } from './utils'; 4 | import { login, logout } from './backend'; 5 | 6 | export function LoginPage() { 7 | const { goToPage, showAlert } = useContext(PageContext); 8 | const [username, setUsername] = useState(''); 9 | const [password, setPassword] = useState(''); 10 | 11 | useAsyncEffect(logout, []); 12 | 13 | const onClickLogin = async () => { 14 | if (username === '') return showAlert('Username missing.'); 15 | if (password === '') return showAlert('Master password missing.'); 16 | const res = await login(username, password); 17 | if (!res.ok) return showAlert(res.error); 18 | goToPage('start'); 19 | }; 20 | 21 | return ( 22 | 23 | e.key == 'Enter' && onClickLogin()}> 24 | 25 | Scop 26 | 27 | 28 | Welcome back 29 | setUsername(e.target.value)} /> 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /ui/SignUpPage.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useState } from 'react'; 2 | import { Stack, Button, TextField, Typography } from '@mui/material'; 3 | import { PageContext, PageProps, PasswordField } from './utils'; 4 | import { create_account } from './backend'; 5 | 6 | export function SignUpPage() { 7 | const { goToPage, showAlert } = useContext(PageContext); 8 | const [username, setUsername] = useState(''); 9 | const [password, setPassword] = useState(''); 10 | const [passwordCopy, setPasswordCopy] = useState(''); 11 | 12 | const onClickLogin = async () => { 13 | if (username === '') return showAlert('Username missing.'); 14 | if (password === '') return showAlert('Master password missing.'); 15 | if (password !== passwordCopy) return showAlert('Passwords do not match'); 16 | const res = await create_account(username, password); 17 | if (!res.ok) return showAlert(res.error); 18 | goToPage('start'); 19 | }; 20 | 21 | return ( 22 | 23 | Create an account 24 | e.key == 'Enter' && onClickLogin()}> 25 | setUsername(e.target.value)} /> 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /ui/StartPage.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useState } from 'react'; 2 | import { Stack, Fab, Typography, IconButton, Paper, Tooltip, Dialog, DialogContent, DialogActions, Button, DialogTitle, Divider, Box } from '@mui/material'; 3 | import { Add, Person, Key, Delete, MoreHoriz } from '@mui/icons-material'; 4 | import { useAsyncEffect, PageContext } from './utils'; 5 | import { fetch_credentials, remove_credentials, copy_to_clipboard, get_credentials_info, Credentials } from './backend'; 6 | 7 | function CredentialsDialog({ name, onClose }: { name: string, onClose: () => void }) { 8 | const { showAlert } = useContext(PageContext); 9 | const [{ username, password }, setCredentials] = useState({ username: '', password: '' }); 10 | 11 | useAsyncEffect(async () => { 12 | if (!name) return; 13 | const res = await get_credentials_info(name); 14 | if (!res.ok) return showAlert(res.error); 15 | setCredentials(res.value); 16 | }, [name]); 17 | 18 | return ( 19 | 20 | Credentials Details 21 | 22 | 23 | Name 24 | {name} 25 | 26 | Username 27 | {username} 28 | 29 | Password 30 | {password} 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | ); 39 | } 40 | 41 | export function StartPage() { 42 | const { goToPage, showAlert } = useContext(PageContext); 43 | const [credentials, setCredentials] = useState([] as string[]); 44 | const [credentialsToRemove, setCredentialsToRemove] = useState(''); 45 | const [credentialsToShow, setCredentialsToShow] = useState(''); 46 | 47 | const populateCredentials = async () => { 48 | const res = await fetch_credentials(); 49 | if (!res.ok) return showAlert(res.error); 50 | setCredentials(res.value) 51 | }; 52 | 53 | useAsyncEffect(populateCredentials, []); 54 | 55 | const copyValue = async (name: string, thing: 'username' | 'password') => { 56 | const res = await copy_to_clipboard(name, thing); 57 | if (!res.ok) return showAlert(res.error); 58 | showAlert(`${thing} copied to clipboard`, 'success'); 59 | }; 60 | 61 | const onRemoveCredentials = async () => { 62 | setCredentialsToRemove(''); 63 | const res = await remove_credentials(credentialsToRemove); 64 | if (!res.ok) return showAlert(res.error); 65 | await populateCredentials(); 66 | } 67 | 68 | return <> 69 | 70 | Credentials 71 | 72 | {credentials.length > 0 73 | ? credentials.sort().map(name => 74 | 75 | {name} 76 | 77 | } onClick={() => copyValue(name, 'username')}/> 78 | 79 | 80 | } onClick={() => copyValue(name, 'password')}/> 81 | 82 | 83 | } onClick={() => setCredentialsToRemove(name)}/> 84 | 85 | 86 | } onClick={() => setCredentialsToShow(name)}/> 87 | 88 | 89 | ) 90 | : "Press the '+' button to add credentials" 91 | } 92 | 93 | 94 | 95 | } color='primary' sx={{ position: 'fixed', bottom: 20, right: 20 }} onClick={() => goToPage('add')}/> 96 | 97 | setCredentialsToRemove('')}> 98 | Delete credentials? 99 | 100 | 101 | 102 | 103 | 104 | setCredentialsToShow('')}/> 105 | ; 106 | } 107 | -------------------------------------------------------------------------------- /ui/backend.tsx: -------------------------------------------------------------------------------- 1 | import { invoke, InvokeArgs } from '@tauri-apps/api/tauri'; 2 | 3 | export type Credentials = { username: string, password: string }; 4 | 5 | async function call(fn: string, args?: InvokeArgs): Promise<{ ok: true; value: T } | { ok: false; error: string }> { 6 | try { 7 | return { ok: true, value: await invoke(fn, args) }; 8 | } catch (e: any) { 9 | return { ok: false, error: e.error }; 10 | } 11 | } 12 | 13 | export const login = (username: string, password: string) => call('login', { username, password }); 14 | export const logout = () => call('logout'); 15 | export const create_account = (username: string, password: string) => call('create_account', { username, password }); 16 | export const fetch_credentials = () => call('fetch_credentials'); 17 | export const add_credentials = (name: string, username: string, password: string) => call('add_credentials', { name, username, password }); 18 | export const remove_credentials = (name: string) => call('remove_credentials', { name }); 19 | export const get_credentials_info = (name: string) => call('get_credentials_info', { name }); 20 | export const generate_password = (length: number, types: string[]) => call('generate_password', { length, types }); 21 | export const copy_to_clipboard = (name: string, thing: 'username' | 'password') => call('copy_to_clipboard', { name, thing }); 22 | export const window_close = () => call('window_close'); 23 | export const window_minimize = () => call('window_minimize'); 24 | export const window_toggle_fullscreen = () => call('window_toggle_fullscreen'); 25 | -------------------------------------------------------------------------------- /ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Tauri PW Manager 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /ui/main.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom/client'; 2 | import React, { useState } from 'react'; 3 | import { CssBaseline, ThemeProvider, createTheme, Snackbar, Alert, AlertColor, IconButton, Box, Tooltip } from '@mui/material'; 4 | import { ArrowBack, Close, Fullscreen, Minimize } from '@mui/icons-material'; 5 | import { window_close, window_minimize, window_toggle_fullscreen } from './backend'; 6 | import { Page, PageContext } from './utils'; 7 | import { LoginPage } from './LoginPage'; 8 | import { SignUpPage } from './SignUpPage'; 9 | import { StartPage } from './StartPage'; 10 | import { AddPage } from './AddPage'; 11 | // @ts-ignore 12 | import CascadiaMono from '../assets/CascadiaMono.woff2'; 13 | 14 | const theme = createTheme({ 15 | palette: { 16 | mode: 'dark', 17 | background: { 18 | default: 'transparent', 19 | paper: '#2c2f3d', 20 | }, 21 | text: { 22 | primary: '#ffffff', 23 | secondary: '#57c7ff', 24 | disabled: 'rgba(255, 255, 255, 0.5)' 25 | }, 26 | primary: { main: '#ff6ac1' }, 27 | secondary: { main: '#ff6ac1' }, 28 | }, 29 | typography: { fontFamily: 'CascadiaMono' }, 30 | components: { 31 | MuiTextField: { 32 | defaultProps: { 33 | spellCheck: false 34 | }, 35 | styleOverrides: { 36 | root: { 37 | minWidth: '300px' 38 | } 39 | } 40 | }, 41 | MuiCssBaseline: { 42 | styleOverrides: ` 43 | @font-face { 44 | font-family: 'CascadiaMono'; 45 | src: url(${CascadiaMono}) format('woff2'); 46 | } 47 | `, 48 | }, 49 | } 50 | }); 51 | 52 | const TitleBar = () => ( 53 | 54 | 55 | } onClick={window_minimize} /> 56 | 57 | 58 | } onClick={window_toggle_fullscreen} /> 59 | 60 | 61 | } onClick={window_close} /> 62 | 63 | 64 | ); 65 | 66 | const backPages = { login: undefined, signup: 'login', start: 'login', add: 'start' }; 67 | const pageComponents = { login: LoginPage, signup: SignUpPage, start: StartPage, add: AddPage }; 68 | 69 | function App() { 70 | const [page, setPage] = useState('login' as Page); 71 | const [{ m, severity }, setMessage] = useState({ m: '', severity: 'error' as AlertColor }); 72 | const [showMessage, setShowMessage] = useState(false); 73 | const goToPage = (page: Page) => { 74 | setPage(page); 75 | setShowMessage(false); 76 | }; 77 | const showAlert = (m: string, severity: AlertColor = 'error') => { 78 | setMessage({ m, severity }); 79 | setShowMessage(true); 80 | }; 81 | return <> 82 | 83 | {page !== 'login' && } sx={{ position: 'fixed', top: 10, left: 10 }} onClick={() => goToPage(backPages[page] as Page)}/>} 84 | 85 | {React.createElement(pageComponents[page])} 86 | 87 | setShowMessage(false)}> 88 | setShowMessage(false)}>{m} 89 | 90 | ; 91 | } 92 | 93 | ReactDOM.createRoot(document.getElementById('root')!).render( 94 | 95 | 96 | 97 | 98 | ); 99 | -------------------------------------------------------------------------------- /ui/utils.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, createContext } from 'react'; 2 | import { IconButton, AlertColor, TextField, InputAdornment, Tooltip, createSvgIcon } from '@mui/material'; 3 | import { Visibility, VisibilityOff } from '@mui/icons-material'; 4 | 5 | export type Page = 'login' | 'signup' | 'start' | 'add'; 6 | 7 | export type PageProps = { goToPage: (p: Page) => void, showAlert: (m: string, severity?: AlertColor) => void }; 8 | 9 | export const PageContext = createContext({} as PageProps); 10 | 11 | export function useAsyncEffect(effect: () => Promise, deps: any[]) { 12 | useEffect(() => { effect().catch(console.error); }, deps) 13 | } 14 | 15 | export function PasswordField({ label, value, onChange }: { label: string, value: string, onChange: (s: string) => void}) { 16 | const [show, setShow] = useState(false); 17 | const endAdornment = ( 18 | 19 | 20 | : } tabIndex={-1} onClick={() => setShow(!show)} edge="end"/> 21 | 22 | 23 | ); 24 | return onChange(e.target.value)} type={show ? 'text' : 'password'} InputProps={{ endAdornment }}/>; 25 | } 26 | 27 | export const AppIcon = createSvgIcon(, 'App'); 28 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | 4 | export default defineConfig({ 5 | root: './ui', 6 | plugins: [ react() ], 7 | server: { 8 | host: '127.0.0.1', 9 | port: 3000, 10 | strictPort: true, 11 | }, 12 | build: { 13 | outDir: './build', 14 | emptyOutDir: true, 15 | }, 16 | clearScreen: false, 17 | logLevel: 'warn', 18 | }); 19 | --------------------------------------------------------------------------------