Compare commits

...

22 Commits

Author SHA1 Message Date
Joseph Montanaro
41f8e8f2ab start working on exec subcommand 2023-05-02 15:36:00 -07:00
Joseph Montanaro
e8b8dc2976 cargo update 2023-05-02 15:24:46 -07:00
Joseph Montanaro
ddf865d0b4 switch to tokio RwLock instead of std 2023-05-02 15:24:35 -07:00
96bbc2dbc2 session renewal 2023-05-02 11:33:18 -07:00
161148d1f6 store base credentials as well as session credentials 2023-05-01 23:03:34 -07:00
760987f09b show approval errors in approval view 2023-05-01 16:53:24 -07:00
a75f34865e return to previous view after approval flow 2023-05-01 13:27:28 -07:00
886fcd9bb8 restrictive CSP and tauri allowlist 2023-05-01 09:05:46 -07:00
55775b6b05 move error dialog to trait 2023-04-30 14:10:21 -07:00
871dedf0a3 display setup errors 2023-04-30 10:52:46 -07:00
913148a75a minor tweaks 2023-04-29 10:01:45 -07:00
e746963052 change frontpage image and toast animation 2023-04-28 22:34:50 -07:00
b761d3b493 find data dir properly 2023-04-28 22:34:17 -07:00
c5dcc2e50a handle errors on config update 2023-04-28 14:33:23 -07:00
70d71ce14e restart listener when config changes 2023-04-28 14:33:04 -07:00
Joseph Montanaro
33a5600a30 prevent NumericSetting from accepting non-numeric inputs 2023-04-27 16:15:19 -07:00
Joseph Montanaro
741169d807 start on login 2023-04-27 14:24:08 -07:00
ebc00a5df6 confirm passphrase 2023-04-26 17:13:58 -07:00
c2cc007a81 display tweaks and approval page timing 2023-04-26 17:06:37 -07:00
4aab08e6f0 save settings to db 2023-04-26 15:49:08 -07:00
12d9d733a5 fix circular imports from routing 2023-04-26 13:05:51 -07:00
35271049dd settings page 2023-04-25 22:10:28 -07:00
33 changed files with 2361 additions and 2421 deletions

2284
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -17,7 +17,8 @@ tauri-build = { version = "1.0.4", features = [] }
[dependencies] [dependencies]
serde_json = "1.0" serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
tauri = { version = "1.0.5", features = ["api-all", "system-tray"] } tauri = { version = "1.2", features = ["dialog", "os-all", "system-tray"] }
tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "dev" }
sodiumoxide = "0.2.7" sodiumoxide = "0.2.7"
tokio = { version = ">=1.19", features = ["full"] } tokio = { version = ">=1.19", features = ["full"] }
sqlx = { version = "0.6.2", features = ["sqlite", "runtime-tokio-rustls"] } sqlx = { version = "0.6.2", features = ["sqlite", "runtime-tokio-rustls"] }
@ -31,14 +32,16 @@ thiserror = "1.0.38"
once_cell = "1.16.0" once_cell = "1.16.0"
strum = "0.24" strum = "0.24"
strum_macros = "0.24" strum_macros = "0.24"
auto-launch = "0.4.0"
dirs = "5.0"
[features] [features]
# by default Tauri runs in production mode # by default Tauri runs in production mode
# when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL # when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL
default = [ "custom-protocol" ] default = ["custom-protocol"]
# this feature is used used for production builds where `devPath` points to the filesystem # this feature is used used for production builds where `devPath` points to the filesystem
# DO NOT remove this # DO NOT remove this
custom-protocol = [ "tauri/custom-protocol" ] custom-protocol = ["tauri/custom-protocol"]
# [profile.dev.build-override] # [profile.dev.build-override]
# opt-level = 3 # opt-level = 3

View File

@ -3,11 +3,12 @@ CREATE TABLE credentials (
access_key_id TEXT NOT NULL, access_key_id TEXT NOT NULL,
secret_key_enc BLOB NOT NULL, secret_key_enc BLOB NOT NULL,
salt BLOB NOT NULL, salt BLOB NOT NULL,
nonce BLOB NOT NULL nonce BLOB NOT NULL,
created_at INTEGER NOT NULL
); );
CREATE TABLE config ( CREATE TABLE config (
name TEXT NOT NULL, name TEXT UNIQUE NOT NULL,
data TEXT NOT NULL data TEXT NOT NULL
); );

View File

@ -1,9 +1,13 @@
use netstat2::{AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo}; use netstat2::{AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo};
use tauri::Manager;
use sysinfo::{System, SystemExt, Pid, PidExt, ProcessExt}; use sysinfo::{System, SystemExt, Pid, PidExt, ProcessExt};
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
use crate::errors::*; use crate::{
use crate::get_state; errors::*,
config::AppConfig,
state::AppState,
};
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Hash)] #[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Hash)]
@ -13,13 +17,18 @@ pub struct Client {
} }
fn get_associated_pids(local_port: u16) -> Result<Vec<u32>, netstat2::error::Error> { async fn get_associated_pids(local_port: u16) -> Result<Vec<u32>, netstat2::error::Error> {
let state = crate::APP.get().unwrap().state::<AppState>();
let AppConfig {
listen_addr: app_listen_addr,
listen_port: app_listen_port,
..
} = *state.config.read().await;
let sockets_iter = netstat2::iterate_sockets_info( let sockets_iter = netstat2::iterate_sockets_info(
AddressFamilyFlags::IPV4, AddressFamilyFlags::IPV4,
ProtocolFlags::TCP ProtocolFlags::TCP
)?; )?;
get_state!(config as app_config);
for item in sockets_iter { for item in sockets_iter {
let sock_info = item?; let sock_info = item?;
let proto_info = match sock_info.protocol_socket_info { let proto_info = match sock_info.protocol_socket_info {
@ -28,9 +37,9 @@ fn get_associated_pids(local_port: u16) -> Result<Vec<u32>, netstat2::error::Err
}; };
if proto_info.local_port == local_port if proto_info.local_port == local_port
&& proto_info.remote_port == app_config.listen_port && proto_info.remote_port == app_listen_port
&& proto_info.local_addr == app_config.listen_addr && proto_info.local_addr == app_listen_addr
&& proto_info.remote_addr == app_config.listen_addr && proto_info.remote_addr == app_listen_addr
{ {
return Ok(sock_info.associated_pids) return Ok(sock_info.associated_pids)
} }
@ -40,10 +49,10 @@ fn get_associated_pids(local_port: u16) -> Result<Vec<u32>, netstat2::error::Err
// Theoretically, on some systems, multiple processes can share a socket // Theoretically, on some systems, multiple processes can share a socket
pub fn get_clients(local_port: u16) -> Result<Vec<Option<Client>>, ClientInfoError> { pub async fn get_clients(local_port: u16) -> Result<Vec<Option<Client>>, ClientInfoError> {
let mut clients = Vec::new(); let mut clients = Vec::new();
let mut sys = System::new(); let mut sys = System::new();
for p in get_associated_pids(local_port)? { for p in get_associated_pids(local_port).await? {
let pid = Pid::from_u32(p); let pid = Pid::from_u32(p);
sys.refresh_process(pid); sys.refresh_process(pid);
let proc = sys.process(pid) let proc = sys.process(pid)

View File

@ -1,6 +1,7 @@
use std::net::Ipv4Addr; use std::net::Ipv4Addr;
use std::path::PathBuf; use std::path::PathBuf;
use auto_launch::AutoLaunchBuilder;
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
use sqlx::SqlitePool; use sqlx::SqlitePool;
@ -17,6 +18,8 @@ pub struct AppConfig {
pub rehide_ms: u64, pub rehide_ms: u64,
#[serde(default = "default_start_minimized")] #[serde(default = "default_start_minimized")]
pub start_minimized: bool, pub start_minimized: bool,
#[serde(default = "default_start_on_login")]
pub start_on_login: bool,
} }
@ -27,12 +30,14 @@ impl Default for AppConfig {
listen_port: default_listen_port(), listen_port: default_listen_port(),
rehide_ms: default_rehide_ms(), rehide_ms: default_rehide_ms(),
start_minimized: default_start_minimized(), start_minimized: default_start_minimized(),
start_on_login: default_start_on_login(),
} }
} }
} }
pub async fn load(pool: &SqlitePool) -> Result<AppConfig, SetupError> { impl AppConfig {
pub async fn load(pool: &SqlitePool) -> Result<AppConfig, SetupError> {
let res = sqlx::query!("SELECT * from config where name = 'main'") let res = sqlx::query!("SELECT * from config where name = 'main'")
.fetch_optional(pool) .fetch_optional(pool)
.await?; .await?;
@ -43,24 +48,60 @@ pub async fn load(pool: &SqlitePool) -> Result<AppConfig, SetupError> {
}; };
Ok(serde_json::from_str(&row.data)?) Ok(serde_json::from_str(&row.data)?)
}
pub async fn save(&self, pool: &SqlitePool) -> Result<(), sqlx::error::Error> {
let data = serde_json::to_string(self).unwrap();
sqlx::query(
"INSERT INTO config (name, data) VALUES ('main', ?)
ON CONFLICT (name) DO UPDATE SET data = ?"
)
.bind(&data)
.bind(&data)
.execute(pool)
.await?;
Ok(())
}
} }
pub fn get_or_create_db_path() -> PathBuf { pub fn set_auto_launch(is_configured: bool) -> Result<(), SetupError> {
if cfg!(debug_assertions) { let path_buf = std::env::current_exe()
return PathBuf::from("./creddy.db"); .map_err(|e| auto_launch::Error::Io(e))?;
let path = path_buf
.to_string_lossy();
let auto = AutoLaunchBuilder::new()
.set_app_name("Creddy")
.set_app_path(&path)
.build()?;
let is_enabled = auto.is_enabled()?;
if is_configured && !is_enabled {
auto.enable()?;
}
else if !is_configured && is_enabled {
auto.disable()?;
} }
let mut parent = std::env::var("HOME") Ok(())
.map(|h| { }
let mut p = PathBuf::from(h);
p.push(".config");
p
})
.unwrap_or(PathBuf::from("."));
parent.push("creddy.db");
parent pub fn get_or_create_db_path() -> Result<PathBuf, DataDirError> {
// debug_assertions doesn't always mean we are running in dev
if cfg!(debug_assertions) && std::env::var("HOME").is_ok() {
return Ok(PathBuf::from("./creddy.db"));
}
let mut path = dirs::data_dir()
.ok_or(DataDirError::NotFound)?;
std::fs::create_dir_all(&path)?;
path.push("creddy.db");
Ok(path)
} }
@ -74,7 +115,7 @@ fn default_listen_port() -> u16 {
} }
fn default_listen_addr() -> Ipv4Addr { Ipv4Addr::LOCALHOST } fn default_listen_addr() -> Ipv4Addr { Ipv4Addr::LOCALHOST }
fn default_rehide_ms() -> u64 { 1000 } fn default_rehide_ms() -> u64 { 1000 }
// start minimized and on login only in production mode
fn default_start_minimized() -> bool { !cfg!(debug_assertions) } // default to start-minimized in production only fn default_start_minimized() -> bool { !cfg!(debug_assertions) }
fn default_start_on_login() -> bool { !cfg!(debug_assertions) }

View File

@ -1,9 +1,9 @@
use std::error::Error; use std::error::Error;
use std::convert::AsRef; use std::convert::AsRef;
use std::sync::mpsc;
use strum_macros::AsRefStr; use strum_macros::AsRefStr;
use thiserror::Error as ThisError; use thiserror::Error as ThisError;
use aws_sdk_sts::{ use aws_sdk_sts::{
types::SdkError as AwsSdkError, types::SdkError as AwsSdkError,
error::GetSessionTokenError, error::GetSessionTokenError,
@ -12,32 +12,29 @@ use sqlx::{
error::Error as SqlxError, error::Error as SqlxError,
migrate::MigrateError, migrate::MigrateError,
}; };
use tauri::api::dialog::{
MessageDialogBuilder,
MessageDialogKind,
};
use serde::{Serialize, Serializer, ser::SerializeMap}; use serde::{Serialize, Serializer, ser::SerializeMap};
// pub struct SerializeError<E> { pub trait ErrorPopup {
// pub err: E, fn error_popup(self, title: &str);
// } }
// impl<E: std::error::Error> Serialize for SerializeError<E> impl<E: Error> ErrorPopup for Result<(), E> {
// { fn error_popup(self, title: &str) {
// fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { if let Err(e) = self {
// let mut map = serializer.serialize_map(None)?; let (tx, rx) = mpsc::channel();
// map.serialize_entry("msg", &format!("{}", self.err))?; MessageDialogBuilder::new(title, format!("{e}"))
// if let Some(src) = self.err.source() { .kind(MessageDialogKind::Error)
// let ser_src = SerializeError { err: src }; .show(move |_| tx.send(true).unwrap());
// map.serialize_entry("source", &ser_src)?;
// }
// map.end()
// }
// }
// impl<E: std::error::Error> From<E> for SerializeError<E> { rx.recv().unwrap();
// fn from(err: E) -> Self { }
// SerializeError { err } }
// } }
// }
fn serialize_basic_err<E, S>(err: &E, serializer: S) -> Result<S::Ok, S::Error> fn serialize_basic_err<E, S>(err: &E, serializer: S) -> Result<S::Ok, S::Error>
@ -87,6 +84,21 @@ pub enum SetupError {
MigrationError(#[from] MigrateError), MigrationError(#[from] MigrateError),
#[error("Error parsing configuration from database")] #[error("Error parsing configuration from database")]
ConfigParseError(#[from] serde_json::Error), ConfigParseError(#[from] serde_json::Error),
#[error("Failed to set up start-on-login: {0}")]
AutoLaunchError(#[from] auto_launch::Error),
#[error("Failed to start listener: {0}")]
ServerSetupError(#[from] std::io::Error),
#[error("Failed to resolve data directory: {0}")]
DataDir(#[from] DataDirError),
}
#[derive(Debug, ThisError, AsRefStr)]
pub enum DataDirError {
#[error("Could not determine data directory")]
NotFound,
#[error("Failed to create data directory: {0}")]
Io(#[from] std::io::Error),
} }
@ -94,9 +106,11 @@ pub enum SetupError {
#[derive(Debug, ThisError, AsRefStr)] #[derive(Debug, ThisError, AsRefStr)]
pub enum SendResponseError { pub enum SendResponseError {
#[error("The specified credentials request was not found")] #[error("The specified credentials request was not found")]
NotFound, // no request with the given id NotFound,
#[error("The specified request was already closed by the client")] #[error("The specified request was already closed by the client")]
Abandoned, // request has already been closed by client Abandoned,
#[error("Could not renew AWS sesssion: {0}")]
SessionRenew(#[from] GetSessionError),
} }
@ -107,7 +121,8 @@ pub enum RequestError {
StreamIOError(#[from] std::io::Error), StreamIOError(#[from] std::io::Error),
// #[error("Received invalid UTF-8 in request")] // #[error("Received invalid UTF-8 in request")]
// InvalidUtf8, // InvalidUtf8,
// MalformedHttpRequest, #[error("HTTP request malformed")]
BadRequest,
#[error("HTTP request too large")] #[error("HTTP request too large")]
RequestTooLarge, RequestTooLarge,
#[error("Error accessing credentials: {0}")] #[error("Error accessing credentials: {0}")]
@ -133,9 +148,13 @@ pub enum GetCredentialsError {
#[derive(Debug, ThisError, AsRefStr)] #[derive(Debug, ThisError, AsRefStr)]
pub enum GetSessionError { pub enum GetSessionError {
#[error("Request completed successfully but no credentials were returned")] #[error("Request completed successfully but no credentials were returned")]
NoCredentials, // SDK returned successfully but credentials are None EmptyResponse, // SDK returned successfully but credentials are None
#[error("Error response from AWS SDK: {0}")] #[error("Error response from AWS SDK: {0}")]
SdkError(#[from] AwsSdkError<GetSessionTokenError>), SdkError(#[from] AwsSdkError<GetSessionTokenError>),
#[error("Could not construt session: credentials are locked")]
CredentialsLocked,
#[error("Could not construct session: no credentials are known")]
CredentialsEmpty,
} }
@ -187,7 +206,6 @@ impl Serialize for SerializeWrapper<&GetSessionTokenError> {
impl_serialize_basic!(SetupError); impl_serialize_basic!(SetupError);
impl_serialize_basic!(SendResponseError);
impl_serialize_basic!(GetCredentialsError); impl_serialize_basic!(GetCredentialsError);
impl_serialize_basic!(ClientInfoError); impl_serialize_basic!(ClientInfoError);
@ -209,6 +227,22 @@ impl Serialize for RequestError {
} }
impl Serialize for SendResponseError {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut map = serializer.serialize_map(None)?;
map.serialize_entry("code", self.as_ref())?;
map.serialize_entry("msg", &format!("{self}"))?;
match self {
SendResponseError::SessionRenew(src) => map.serialize_entry("source", &src)?,
_ => serialize_upstream_err(self, &mut map)?,
}
map.end()
}
}
impl Serialize for GetSessionError { impl Serialize for GetSessionError {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut map = serializer.serialize_map(None)?; let mut map = serializer.serialize_map(None)?;

View File

@ -4,7 +4,7 @@ use tauri::State;
use crate::errors::*; use crate::errors::*;
use crate::config::AppConfig; use crate::config::AppConfig;
use crate::clientinfo::Client; use crate::clientinfo::Client;
use crate::state::{AppState, Session, Credentials}; use crate::state::{AppState, Session, BaseCredentials};
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
@ -29,32 +29,32 @@ pub enum Approval {
#[tauri::command] #[tauri::command]
pub fn respond(response: RequestResponse, app_state: State<'_, AppState>) -> Result<(), String> { pub async fn respond(response: RequestResponse, app_state: State<'_, AppState>) -> Result<(), SendResponseError> {
app_state.send_response(response) app_state.send_response(response).await
.map_err(|e| format!("Error responding to request: {e}"))
} }
#[tauri::command] #[tauri::command]
pub async fn unlock(passphrase: String, app_state: State<'_, AppState>) -> Result<(), UnlockError> { pub async fn unlock(passphrase: String, app_state: State<'_, AppState>) -> Result<(), UnlockError> {
app_state.decrypt(&passphrase).await app_state.unlock(&passphrase).await
} }
#[tauri::command] #[tauri::command]
pub fn get_session_status(app_state: State<'_, AppState>) -> String { pub async fn get_session_status(app_state: State<'_, AppState>) -> Result<String, ()> {
let session = app_state.session.read().unwrap(); let session = app_state.session.read().await;
match *session { let status = match *session {
Session::Locked(_) => "locked".into(), Session::Locked(_) => "locked".into(),
Session::Unlocked(_) => "unlocked".into(), Session::Unlocked{..} => "unlocked".into(),
Session::Empty => "empty".into() Session::Empty => "empty".into()
} };
Ok(status)
} }
#[tauri::command] #[tauri::command]
pub async fn save_credentials( pub async fn save_credentials(
credentials: Credentials, credentials: BaseCredentials,
passphrase: String, passphrase: String,
app_state: State<'_, AppState> app_state: State<'_, AppState>
) -> Result<(), UnlockError> { ) -> Result<(), UnlockError> {
@ -63,7 +63,16 @@ pub async fn save_credentials(
#[tauri::command] #[tauri::command]
pub fn get_config(app_state: State<'_, AppState>) -> AppConfig { pub async fn get_config(app_state: State<'_, AppState>) -> Result<AppConfig, ()> {
let config = app_state.config.read().unwrap(); let config = app_state.config.read().await;
config.clone() Ok(config.clone())
}
#[tauri::command]
pub async fn save_config(config: AppConfig, app_state: State<'_, AppState>) -> Result<(), String> {
app_state.update_config(config)
.await
.map_err(|e| format!("Error saving config: {e}"))?;
Ok(())
} }

View File

@ -2,9 +2,20 @@
all(not(debug_assertions), target_os = "windows"), all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows" windows_subsystem = "windows"
)] )]
use std::error::Error;
use tauri::{AppHandle, Manager, async_runtime as rt};
use once_cell::sync::OnceCell; use once_cell::sync::OnceCell;
use sqlx::{
SqlitePool,
sqlite::SqlitePoolOptions,
sqlite::SqliteConnectOptions,
};
use tauri::{
App,
AppHandle,
Manager,
async_runtime as rt,
};
mod config; mod config;
mod errors; mod errors;
@ -14,20 +25,48 @@ mod state;
mod server; mod server;
mod tray; mod tray;
use crate::errors::*; use config::AppConfig;
use server::Server;
use errors::*;
use state::AppState; use state::AppState;
pub static APP: OnceCell<AppHandle> = OnceCell::new(); pub static APP: OnceCell<AppHandle> = OnceCell::new();
fn main() {
let initial_state = match rt::block_on(state::AppState::load()) {
Ok(state) => state,
Err(e) => {eprintln!("{}", e); return;}
};
async fn setup(app: &mut App) -> Result<(), Box<dyn Error>> {
APP.set(app.handle()).unwrap();
let conn_opts = SqliteConnectOptions::new()
.filename(config::get_or_create_db_path()?)
.create_if_missing(true);
let pool_opts = SqlitePoolOptions::new();
let pool: SqlitePool = pool_opts.connect_with(conn_opts).await?;
sqlx::migrate!().run(&pool).await?;
let conf = AppConfig::load(&pool).await?;
let session = AppState::load_creds(&pool).await?;
let srv = Server::new(conf.listen_addr, conf.listen_port, app.handle()).await?;
config::set_auto_launch(conf.start_on_login)?;
if !conf.start_minimized {
app.get_window("main")
.ok_or(RequestError::NoMainWindow)?
.show()?;
}
let state = AppState::new(conf, session, srv, pool);
app.manage(state);
Ok(())
}
fn run() -> tauri::Result<()> {
tauri::Builder::default() tauri::Builder::default()
.manage(initial_state) .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
app.get_window("main")
.map(|w| w.show().error_popup("Failed to show main window"));
}))
.system_tray(tray::create()) .system_tray(tray::create())
.on_system_tray_event(tray::handle_event) .on_system_tray_event(tray::handle_event)
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
@ -36,23 +75,10 @@ fn main() {
ipc::get_session_status, ipc::get_session_status,
ipc::save_credentials, ipc::save_credentials,
ipc::get_config, ipc::get_config,
ipc::save_config,
]) ])
.setup(|app| { .setup(|app| rt::block_on(setup(app)))
APP.set(app.handle()).unwrap(); .build(tauri::generate_context!())?
let state = app.state::<AppState>();
let config = state.config.read().unwrap();
let addr = std::net::SocketAddrV4::new(config.listen_addr, config.listen_port);
tauri::async_runtime::spawn(server::serve(addr, app.handle()));
if !config.start_minimized {
app.get_window("main")
.ok_or(RequestError::NoMainWindow)?
.show()?;
}
Ok(())
})
.build(tauri::generate_context!())
.expect("error while running tauri application")
.run(|app, run_event| match run_event { .run(|app, run_event| match run_event {
tauri::RunEvent::WindowEvent { label, event, .. } => match event { tauri::RunEvent::WindowEvent { label, event, .. } => match event {
tauri::WindowEvent::CloseRequested { api, .. } => { tauri::WindowEvent::CloseRequested { api, .. } => {
@ -62,39 +88,12 @@ fn main() {
_ => () _ => ()
} }
_ => () _ => ()
}) });
Ok(())
} }
macro_rules! get_state { fn main() {
($prop:ident as $name:ident) => { run().error_popup("Creddy failed to start");
use tauri::Manager;
let app = crate::APP.get().unwrap(); // as long as the app is running, this is fine
let state = app.state::<crate::state::AppState>();
let $name = state.$prop.read().unwrap(); // only panics if another thread has already panicked
};
(config.$prop:ident as $name:ident) => {
use tauri::Manager;
let app = crate::APP.get().unwrap();
let state = app.state::<crate::state::AppState>();
let config = state.config.read().unwrap();
let $name = config.$prop;
};
(mut $prop:ident as $name:ident) => {
use tauri::Manager;
let app = crate::APP.get().unwrap();
let state = app.state::<crate::state::AppState>();
let $name = state.$prop.write().unwrap();
};
(mut config.$prop:ident as $name:ident) => {
use tauri::Manager;
let app = crate::APP.get().unwrap();
let state = app.state::<crate::state::AppState>();
let config = state.config.write().unwrap();
let $name = config.$prop;
}
} }
pub(crate) use get_state;

View File

@ -1,12 +1,21 @@
use core::time::Duration; use core::time::Duration;
use std::io; use std::io;
use std::net::{SocketAddr, SocketAddrV4}; use std::net::{
use tokio::net::{TcpListener, TcpStream}; Ipv4Addr,
SocketAddr,
SocketAddrV4,
};
use tokio::net::{
TcpListener,
TcpStream,
};
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::oneshot; use tokio::sync::oneshot;
use tokio::time::sleep; use tokio::time::sleep;
use tauri::{AppHandle, Manager}; use tauri::{AppHandle, Manager};
use tauri::async_runtime as rt;
use tauri::async_runtime::JoinHandle;
use crate::{clientinfo, clientinfo::Client}; use crate::{clientinfo, clientinfo::Client};
use crate::errors::*; use crate::errors::*;
@ -22,10 +31,10 @@ struct Handler {
} }
impl Handler { impl Handler {
fn new(stream: TcpStream, app: AppHandle) -> Self { async fn new(stream: TcpStream, app: AppHandle) -> Self {
let state = app.state::<AppState>(); let state = app.state::<AppState>();
let (chan_send, chan_recv) = oneshot::channel(); let (chan_send, chan_recv) = oneshot::channel();
let request_id = state.register_request(chan_send); let request_id = state.register_request(chan_send).await;
Handler { Handler {
request_id, request_id,
stream, stream,
@ -39,13 +48,13 @@ impl Handler {
eprintln!("{e}"); eprintln!("{e}");
} }
let state = self.app.state::<AppState>(); let state = self.app.state::<AppState>();
state.unregister_request(self.request_id); state.unregister_request(self.request_id).await;
} }
async fn try_handle(&mut self) -> Result<(), RequestError> { async fn try_handle(&mut self) -> Result<(), RequestError> {
let _ = self.recv_request().await?; let _ = self.recv_request().await?;
let clients = self.get_clients()?; let clients = self.get_clients().await?;
if self.includes_banned(&clients) { if self.includes_banned(&clients).await {
self.stream.write(b"HTTP/1.0 403 Access Denied\r\n\r\n").await?; self.stream.write(b"HTTP/1.0 403 Access Denied\r\n\r\n").await?;
return Ok(()) return Ok(())
} }
@ -59,7 +68,7 @@ impl Handler {
Approval::Denied => { Approval::Denied => {
let state = self.app.state::<AppState>(); let state = self.app.state::<AppState>();
for client in req.clients { for client in req.clients {
state.add_ban(client, self.app.clone()); state.add_ban(client).await;
} }
} }
} }
@ -68,12 +77,12 @@ impl Handler {
// and b) there are no other pending requests // and b) there are no other pending requests
let state = self.app.state::<AppState>(); let state = self.app.state::<AppState>();
let delay = { let delay = {
let config = state.config.read().unwrap(); let config = state.config.read().await;
Duration::from_millis(config.rehide_ms) Duration::from_millis(config.rehide_ms)
}; };
sleep(delay).await; sleep(delay).await;
if !starting_visibility && state.req_count() == 0 { if !starting_visibility && state.req_count().await == 0 {
let window = self.app.get_window("main").ok_or(RequestError::NoMainWindow)?; let window = self.app.get_window("main").ok_or(RequestError::NoMainWindow)?;
window.hide()?; window.hide()?;
} }
@ -94,21 +103,31 @@ impl Handler {
println!("{}", std::str::from_utf8(&buf).unwrap()); println!("{}", std::str::from_utf8(&buf).unwrap());
} }
let path = buf.split(|&c| &[c] == b" ")
.skip(1)
.next()
.ok_or(RequestError::BadRequest(buf))?;
Ok(buf) Ok(buf)
} }
fn get_clients(&self) -> Result<Vec<Option<Client>>, RequestError> { async fn get_clients(&self) -> Result<Vec<Option<Client>>, RequestError> {
let peer_addr = match self.stream.peer_addr()? { let peer_addr = match self.stream.peer_addr()? {
SocketAddr::V4(addr) => addr, SocketAddr::V4(addr) => addr,
_ => unreachable!(), // we only listen on IPv4 _ => unreachable!(), // we only listen on IPv4
}; };
let clients = clientinfo::get_clients(peer_addr.port())?; let clients = clientinfo::get_clients(peer_addr.port()).await?;
Ok(clients) Ok(clients)
} }
fn includes_banned(&self, clients: &Vec<Option<Client>>) -> bool { async fn includes_banned(&self, clients: &Vec<Option<Client>>) -> bool {
let state = self.app.state::<AppState>(); let state = self.app.state::<AppState>();
clients.iter().any(|c| state.is_banned(c)) for client in clients {
if state.is_banned(client).await {
return true;
}
}
false
} }
fn show_window(&self) -> Result<bool, RequestError> { fn show_window(&self) -> Result<bool, RequestError> {
@ -147,7 +166,7 @@ impl Handler {
async fn send_credentials(&mut self) -> Result<(), RequestError> { async fn send_credentials(&mut self) -> Result<(), RequestError> {
let state = self.app.state::<AppState>(); let state = self.app.state::<AppState>();
let creds = state.get_creds_serialized()?; let creds = state.serialize_session_creds().await?;
self.stream.write(b"\r\nContent-Length: ").await?; self.stream.write(b"\r\nContent-Length: ").await?;
self.stream.write(creds.as_bytes().len().to_string().as_bytes()).await?; self.stream.write(creds.as_bytes().len().to_string().as_bytes()).await?;
@ -159,18 +178,56 @@ impl Handler {
} }
pub async fn serve(addr: SocketAddrV4, app_handle: AppHandle) -> io::Result<()> { #[derive(Debug)]
let listener = TcpListener::bind(&addr).await?; pub struct Server {
println!("Listening on {addr}"); addr: Ipv4Addr,
port: u16,
app_handle: AppHandle,
task: JoinHandle<()>,
}
impl Server {
pub async fn new(addr: Ipv4Addr, port: u16, app_handle: AppHandle) -> io::Result<Server> {
let task = Self::start_server(addr, port, app_handle.app_handle()).await?;
Ok(Server { addr, port, app_handle, task})
}
pub async fn rebind(&mut self, addr: Ipv4Addr, port: u16) -> io::Result<()> {
if addr == self.addr && port == self.port {
return Ok(())
}
let new_task = Self::start_server(addr, port, self.app_handle.app_handle()).await?;
self.task.abort();
self.addr = addr;
self.port = port;
self.task = new_task;
Ok(())
}
// construct the listener before spawning the task so that we can return early if it fails
async fn start_server(addr: Ipv4Addr, port: u16, app_handle: AppHandle) -> io::Result<JoinHandle<()>> {
let sock_addr = SocketAddrV4::new(addr, port);
let listener = TcpListener::bind(&sock_addr).await?;
let task = rt::spawn(
Self::serve(listener, app_handle.app_handle())
);
Ok(task)
}
async fn serve(listener: TcpListener, app_handle: AppHandle) {
loop { loop {
match listener.accept().await { match listener.accept().await {
Ok((stream, _)) => { Ok((stream, _)) => {
let handler = Handler::new(stream, app_handle.app_handle()); let handler = Handler::new(stream, app_handle.app_handle()).await;
tauri::async_runtime::spawn(handler.handle()); rt::spawn(handler.handle());
}, },
Err(e) => { Err(e) => {
eprintln!("Error accepting connection: {e}"); eprintln!("Error accepting connection: {e}");
} }
} }
} }
}
} }

View File

@ -1,11 +1,22 @@
use core::time::Duration;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::sync::RwLock; use std::time::{
Duration,
SystemTime,
UNIX_EPOCH
};
use aws_smithy_types::date_time::{
DateTime as AwsDateTime,
Format as AwsDateTimeFormat,
};
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
use tokio::sync::oneshot::Sender; use tokio::{
use tokio::time::sleep; sync::oneshot::Sender,
use sqlx::{SqlitePool, sqlite::SqlitePoolOptions, sqlite::SqliteConnectOptions}; sync::RwLock,
time::sleep,
};
use sqlx::SqlitePool;
use sodiumoxide::crypto::{ use sodiumoxide::crypto::{
pwhash, pwhash,
pwhash::Salt, pwhash::Salt,
@ -14,32 +25,47 @@ use sodiumoxide::crypto::{
}; };
use tauri::async_runtime as runtime; use tauri::async_runtime as runtime;
use tauri::Manager; use tauri::Manager;
use serde::Serializer;
use crate::{config, config::AppConfig}; use crate::{config, config::AppConfig};
use crate::ipc; use crate::ipc;
use crate::clientinfo::Client; use crate::clientinfo::Client;
use crate::errors::*; use crate::errors::*;
use crate::server::Server;
#[derive(Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(untagged)] #[serde(rename_all = "PascalCase")]
pub enum Credentials { pub struct BaseCredentials {
#[serde(rename_all = "PascalCase")]
LongLived {
access_key_id: String, access_key_id: String,
secret_access_key: String, secret_access_key: String,
}, }
#[serde(rename_all = "PascalCase")]
ShortLived { #[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "PascalCase")]
pub struct SessionCredentials {
access_key_id: String, access_key_id: String,
secret_access_key: String, secret_access_key: String,
token: String, token: String,
expiration: String, #[serde(serialize_with = "serialize_expiration")]
}, expiration: AwsDateTime,
}
impl SessionCredentials {
fn is_expired(&self) -> bool {
let current_ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap() // doesn't panic because UNIX_EPOCH won't be later than now()
.as_secs();
let expire_ts = self.expiration.secs();
let remaining = expire_ts - (current_ts as i64);
remaining < 60
}
} }
#[derive(Debug)] #[derive(Clone, Debug)]
pub struct LockedCredentials { pub struct LockedCredentials {
access_key_id: String, access_key_id: String,
secret_key_enc: Vec<u8>, secret_key_enc: Vec<u8>,
@ -48,9 +74,12 @@ pub struct LockedCredentials {
} }
#[derive(Debug)] #[derive(Clone, Debug)]
pub enum Session { pub enum Session {
Unlocked(Credentials), Unlocked{
base: BaseCredentials,
session: SessionCredentials,
},
Locked(LockedCredentials), Locked(LockedCredentials),
Empty, Empty,
} }
@ -63,35 +92,25 @@ pub struct AppState {
pub request_count: RwLock<u64>, pub request_count: RwLock<u64>,
pub open_requests: RwLock<HashMap<u64, Sender<ipc::Approval>>>, pub open_requests: RwLock<HashMap<u64, Sender<ipc::Approval>>>,
pub bans: RwLock<std::collections::HashSet<Option<Client>>>, pub bans: RwLock<std::collections::HashSet<Option<Client>>>,
pool: SqlitePool, server: RwLock<Server>,
pool: sqlx::SqlitePool,
} }
impl AppState { impl AppState {
pub async fn load() -> Result<Self, SetupError> { pub fn new(config: AppConfig, session: Session, server: Server, pool: SqlitePool) -> AppState {
let conn_opts = SqliteConnectOptions::new() AppState {
.filename(config::get_or_create_db_path()) config: RwLock::new(config),
.create_if_missing(true); session: RwLock::new(session),
let pool_opts = SqlitePoolOptions::new();
let pool: SqlitePool = pool_opts.connect_with(conn_opts).await?;
sqlx::migrate!().run(&pool).await?;
let creds = Self::load_creds(&pool).await?;
let conf = config::load(&pool).await?;
let state = AppState {
config: RwLock::new(conf),
session: RwLock::new(creds),
request_count: RwLock::new(0), request_count: RwLock::new(0),
open_requests: RwLock::new(HashMap::new()), open_requests: RwLock::new(HashMap::new()),
bans: RwLock::new(HashSet::new()), bans: RwLock::new(HashSet::new()),
server: RwLock::new(server),
pool, pool,
}; }
Ok(state)
} }
async fn load_creds(pool: &SqlitePool) -> Result<Session, SetupError> { pub async fn load_creds(pool: &SqlitePool) -> Result<Session, SetupError> {
let res = sqlx::query!("SELECT * FROM credentials") let res = sqlx::query!("SELECT * FROM credentials ORDER BY created_at desc")
.fetch_optional(pool) .fetch_optional(pool)
.await?; .await?;
let row = match res { let row = match res {
@ -115,13 +134,12 @@ impl AppState {
Ok(Session::Locked(creds)) Ok(Session::Locked(creds))
} }
pub async fn save_creds(&self, creds: Credentials, passphrase: &str) -> Result<(), UnlockError> { pub async fn save_creds(&self, creds: BaseCredentials, passphrase: &str) -> Result<(), UnlockError> {
let (key_id, secret_key) = match creds { let BaseCredentials {access_key_id, secret_access_key} = creds;
Credentials::LongLived {access_key_id, secret_access_key} => {
(access_key_id, secret_access_key) // do this first so that if it fails we don't save bad credentials
}, self.new_session(&access_key_id, &secret_access_key).await?;
_ => unreachable!(),
};
let salt = pwhash::gen_salt(); let salt = pwhash::gen_salt();
let mut key_buf = [0; secretbox::KEYBYTES]; let mut key_buf = [0; secretbox::KEYBYTES];
pwhash::derive_key_interactive(&mut key_buf, passphrase.as_bytes(), &salt).unwrap(); pwhash::derive_key_interactive(&mut key_buf, passphrase.as_bytes(), &salt).unwrap();
@ -129,49 +147,67 @@ impl AppState {
// not sure we need both salt AND nonce given that we generate a // not sure we need both salt AND nonce given that we generate a
// fresh salt every time we encrypt, but better safe than sorry // fresh salt every time we encrypt, but better safe than sorry
let nonce = secretbox::gen_nonce(); let nonce = secretbox::gen_nonce();
let secret_key_enc = secretbox::seal(secret_key.as_bytes(), &nonce, &key); let secret_key_enc = secretbox::seal(secret_access_key.as_bytes(), &nonce, &key);
sqlx::query( sqlx::query(
"INSERT INTO credentials (access_key_id, secret_key_enc, salt, nonce) "INSERT INTO credentials (access_key_id, secret_key_enc, salt, nonce, created_at)
VALUES (?, ?, ?, ?)" VALUES (?, ?, ?, ?, strftime('%s'))"
) )
.bind(&key_id) .bind(&access_key_id)
.bind(&secret_key_enc) .bind(&secret_key_enc)
.bind(&salt.0[0..]) .bind(&salt.0[0..])
.bind(&nonce.0[0..]) .bind(&nonce.0[0..])
.execute(&self.pool) .execute(&self.pool)
.await?; .await?;
self.new_session(&key_id, &secret_key).await?;
Ok(()) Ok(())
} }
pub fn register_request(&self, chan: Sender<ipc::Approval>) -> u64 { pub async fn update_config(&self, new_config: AppConfig) -> Result<(), SetupError> {
let mut live_config = self.config.write().await;
if new_config.start_on_login != live_config.start_on_login {
config::set_auto_launch(new_config.start_on_login)?;
}
if new_config.listen_addr != live_config.listen_addr
|| new_config.listen_port != live_config.listen_port
{
let mut sv = self.server.write().await;
sv.rebind(new_config.listen_addr, new_config.listen_port).await?;
}
new_config.save(&self.pool).await?;
*live_config = new_config;
Ok(())
}
pub async fn register_request(&self, chan: Sender<ipc::Approval>) -> u64 {
let count = { let count = {
let mut c = self.request_count.write().unwrap(); let mut c = self.request_count.write().await;
*c += 1; *c += 1;
c c
}; };
let mut open_requests = self.open_requests.write().unwrap(); let mut open_requests = self.open_requests.write().await;
open_requests.insert(*count, chan); // `count` is the request id open_requests.insert(*count, chan); // `count` is the request id
*count *count
} }
pub fn unregister_request(&self, id: u64) { pub async fn unregister_request(&self, id: u64) {
let mut open_requests = self.open_requests.write().unwrap(); let mut open_requests = self.open_requests.write().await;
open_requests.remove(&id); open_requests.remove(&id);
} }
pub fn req_count(&self) -> usize { pub async fn req_count(&self) -> usize {
let open_requests = self.open_requests.read().unwrap(); let open_requests = self.open_requests.read().await;
open_requests.len() open_requests.len()
} }
pub fn send_response(&self, response: ipc::RequestResponse) -> Result<(), SendResponseError> { pub async fn send_response(&self, response: ipc::RequestResponse) -> Result<(), SendResponseError> {
let mut open_requests = self.open_requests.write().unwrap(); self.renew_session_if_expired().await?;
let mut open_requests = self.open_requests.write().await;
let chan = open_requests let chan = open_requests
.remove(&response.id) .remove(&response.id)
.ok_or(SendResponseError::NotFound) .ok_or(SendResponseError::NotFound)
@ -181,57 +217,75 @@ impl AppState {
.map_err(|_e| SendResponseError::Abandoned) .map_err(|_e| SendResponseError::Abandoned)
} }
pub fn add_ban(&self, client: Option<Client>, app: tauri::AppHandle) { pub async fn add_ban(&self, client: Option<Client>) {
let mut bans = self.bans.write().unwrap(); let mut bans = self.bans.write().await;
bans.insert(client.clone()); bans.insert(client.clone());
runtime::spawn(async move { runtime::spawn(async move {
sleep(Duration::from_secs(5)).await; sleep(Duration::from_secs(5)).await;
let app = crate::APP.get().unwrap();
let state = app.state::<AppState>(); let state = app.state::<AppState>();
let mut bans = state.bans.write().unwrap(); let mut bans = state.bans.write().await;
bans.remove(&client); bans.remove(&client);
}); });
} }
pub fn is_banned(&self, client: &Option<Client>) -> bool { pub async fn is_banned(&self, client: &Option<Client>) -> bool {
self.bans.read().unwrap().contains(&client) self.bans.read().await.contains(&client)
} }
pub async fn decrypt(&self, passphrase: &str) -> Result<(), UnlockError> { pub async fn unlock(&self, passphrase: &str) -> Result<(), UnlockError> {
let (key_id, secret) = { let mut session = self.session.write().await;
// do this all in a block so that we aren't holding a lock across an await let LockedCredentials {
let session = self.session.read().unwrap(); access_key_id,
let locked = match *session { secret_key_enc,
salt,
nonce
} = match *session {
Session::Empty => {return Err(UnlockError::NoCredentials);}, Session::Empty => {return Err(UnlockError::NoCredentials);},
Session::Unlocked(_) => {return Err(UnlockError::NotLocked);}, Session::Unlocked{..} => {return Err(UnlockError::NotLocked);},
Session::Locked(ref c) => c, Session::Locked(ref c) => c,
}; };
let mut key_buf = [0; secretbox::KEYBYTES]; let mut key_buf = [0; secretbox::KEYBYTES];
// pretty sure this only fails if we're out of memory // pretty sure this only fails if we're out of memory
pwhash::derive_key_interactive(&mut key_buf, passphrase.as_bytes(), &locked.salt).unwrap(); pwhash::derive_key_interactive(&mut key_buf, passphrase.as_bytes(), salt).unwrap();
let decrypted = secretbox::open(&locked.secret_key_enc, &locked.nonce, &Key(key_buf)) let decrypted = secretbox::open(secret_key_enc, nonce, &Key(key_buf))
.map_err(|_e| UnlockError::BadPassphrase)?; .map_err(|_e| UnlockError::BadPassphrase)?;
let secret_str = String::from_utf8(decrypted).map_err(|_e| UnlockError::InvalidUtf8)?; let secret_access_key = String::from_utf8(decrypted).map_err(|_e| UnlockError::InvalidUtf8)?;
(locked.access_key_id.clone(), secret_str)
};
self.new_session(&key_id, &secret).await?; let session_creds = self.new_session(access_key_id, &secret_access_key).await?;
*session = Session::Unlocked {
base: BaseCredentials {
access_key_id: access_key_id.clone(),
secret_access_key,
},
session: session_creds
};
Ok(()) Ok(())
} }
pub fn get_creds_serialized(&self) -> Result<String, GetCredentialsError> { // pub async fn serialize_base_creds(&self) -> Result<String, GetCredentialsError> {
let session = self.session.read().unwrap(); // let session = self.session.read().await;
// match *session {
// Session::Unlocked{ref base, ..} => Ok(serde_json::to_string(base).unwrap()),
// Session::Locked(_) => Err(GetCredentialsError::Locked),
// Session::Empty => Err(GetCredentialsError::Empty),
// }
// }
pub async fn serialize_session_creds(&self) -> Result<String, GetCredentialsError> {
let session = self.session.read().await;
match *session { match *session {
Session::Unlocked(ref creds) => Ok(serde_json::to_string(creds).unwrap()), Session::Unlocked{ref session, ..} => Ok(serde_json::to_string(session).unwrap()),
Session::Locked(_) => Err(GetCredentialsError::Locked), Session::Locked(_) => Err(GetCredentialsError::Locked),
Session::Empty => Err(GetCredentialsError::Empty), Session::Empty => Err(GetCredentialsError::Empty),
} }
} }
async fn new_session(&self, key_id: &str, secret_key: &str) -> Result<(), GetSessionError> { async fn new_session(&self, key_id: &str, secret_key: &str) -> Result<SessionCredentials, GetSessionError> {
let creds = aws_sdk_sts::Credentials::new( let creds = aws_sdk_sts::Credentials::new(
key_id, key_id,
secret_key, secret_key,
@ -250,36 +304,58 @@ impl AppState {
.send() .send()
.await?; .await?;
let aws_session = resp.credentials().ok_or(GetSessionError::NoCredentials)?; let aws_session = resp.credentials().ok_or(GetSessionError::EmptyResponse)?;
let access_key_id = aws_session.access_key_id() let access_key_id = aws_session.access_key_id()
.ok_or(GetSessionError::NoCredentials)? .ok_or(GetSessionError::EmptyResponse)?
.to_string(); .to_string();
let secret_access_key = aws_session.secret_access_key() let secret_access_key = aws_session.secret_access_key()
.ok_or(GetSessionError::NoCredentials)? .ok_or(GetSessionError::EmptyResponse)?
.to_string(); .to_string();
let token = aws_session.session_token() let token = aws_session.session_token()
.ok_or(GetSessionError::NoCredentials)? .ok_or(GetSessionError::EmptyResponse)?
.to_string(); .to_string();
let expiration = aws_session.expiration() let expiration = aws_session.expiration()
.ok_or(GetSessionError::NoCredentials)? .ok_or(GetSessionError::EmptyResponse)?
.fmt(aws_smithy_types::date_time::Format::DateTime) .clone();
.unwrap(); // only fails if the d/t is out of range, which it can't be for this format
let mut app_session = self.session.write().unwrap(); let session_creds = SessionCredentials {
let session_creds = Credentials::ShortLived {
access_key_id, access_key_id,
secret_access_key, secret_access_key,
token, token,
expiration, expiration,
}; };
if cfg!(debug_assertions) { #[cfg(debug_assertions)]
println!("Got new session:\n{}", serde_json::to_string(&session_creds).unwrap()); println!("Got new session:\n{}", serde_json::to_string(&session_creds).unwrap());
Ok(session_creds)
} }
*app_session = Session::Unlocked(session_creds); pub async fn renew_session_if_expired(&self) -> Result<bool, GetSessionError> {
match *self.session.write().await {
Ok(()) Session::Unlocked{ref base, ref mut session} => {
if !session.is_expired() {
return Ok(false);
}
let new_session = self.new_session(
&base.access_key_id,
&base.secret_access_key
).await?;
*session = new_session;
Ok(true)
},
Session::Locked(_) => Err(GetSessionError::CredentialsLocked),
Session::Empty => Err(GetSessionError::CredentialsEmpty),
}
} }
} }
fn serialize_expiration<S>(exp: &AwsDateTime, serializer: S) -> Result<S::Ok, S::Error>
where S: Serializer
{
// this only fails if the d/t is out of range, which it can't be for this format
let time_str = exp.fmt(AwsDateTimeFormat::DateTime).unwrap();
serializer.serialize_str(&time_str)
}

View File

@ -12,7 +12,7 @@
}, },
"tauri": { "tauri": {
"allowlist": { "allowlist": {
"all": true "os": {"all": true}
}, },
"bundle": { "bundle": {
"active": true, "active": true,
@ -48,7 +48,10 @@
} }
}, },
"security": { "security": {
"csp": null "csp": {
"default-src": ["'self'"],
"style-src": ["'self'", "'unsafe-inline'"]
}
}, },
"updater": { "updater": {
"active": false "active": false

View File

@ -1,18 +1,23 @@
<script> <script>
import { emit, listen } from '@tauri-apps/api/event'; import { onMount } from 'svelte';
import { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/tauri'; import { invoke } from '@tauri-apps/api/tauri';
import { appState } from './lib/state.js'; import { appState, acceptRequest } from './lib/state.js';
import { currentView } from './lib/routing.js'; import { views, currentView, navigate } from './lib/routing.js';
$views = import.meta.glob('./views/*.svelte', {eager: true});
navigate('Home');
invoke('get_config').then(config => $appState.config = config);
listen('credentials-request', (tauriEvent) => { listen('credentials-request', (tauriEvent) => {
$appState.pendingRequests.put(tauriEvent.payload); $appState.pendingRequests.put(tauriEvent.payload);
}); });
acceptRequest();
</script> </script>
<svelte:component <svelte:component this="{$currentView}" />
this="{$currentView}"
/>
<!-- <svelte:component this="{VIEWS['./views/ShowApproved.svelte'].default}" bind:appState="{appState}" /> -->

153
src/assets/vault_door.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -1,13 +1,11 @@
import { writable, derived } from 'svelte/store'; import { writable, get } from 'svelte/store';
const VIEWS = import.meta.glob('../views/*.svelte', {eager: true});
export let views = writable();
export let currentView = writable(); export let currentView = writable();
export let previousView = writable();
export function navigate(viewName) { export function navigate(viewName) {
let view = VIEWS[`../views/${viewName}.svelte`].default; let v = get(views)[`./views/${viewName}.svelte`].default;
currentView.set(view); currentView.set(v)
} }
navigate('Home');

View File

@ -1,9 +1,33 @@
import { writable } from 'svelte/store'; import { writable, get } from 'svelte/store';
import queue from './queue.js'; import queue from './queue.js';
import { navigate, currentView, previousView } from './routing.js';
export let appState = writable({ export let appState = writable({
currentRequest: null, currentRequest: null,
pendingRequests: queue(), pendingRequests: queue(),
credentialStatus: 'locked', credentialStatus: 'locked',
}); });
export async function acceptRequest() {
let req = await get(appState).pendingRequests.get();
appState.update($appState => {
$appState.currentRequest = req;
return $appState;
});
previousView.set(get(currentView));
navigate('Approve');
}
export function completeRequest() {
appState.update($appState => {
$appState.currentRequest = null;
return $appState;
});
currentView.set(get(previousView));
previousView.set(null);
acceptRequest();
}

View File

@ -1,3 +1,7 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
.btn-alert-error {
@apply bg-transparent hover:bg-[#cd5a5a] border border-error-content text-error-content
}

View File

@ -2,6 +2,8 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
let extraClasses = "";
export {extraClasses as class};
export let slideDuration = 150; export let slideDuration = 150;
let animationClass = ""; let animationClass = "";
@ -49,7 +51,7 @@
</style> </style>
<div in:slide="{{duration: slideDuration}}" class="alert alert-error shadow-lg {animationClass}"> <div in:slide="{{duration: slideDuration}}" class="alert alert-error shadow-lg {animationClass} {extraClasses}">
<div> <div>
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current flex-shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current flex-shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span> <span>

View File

@ -1,6 +1,4 @@
<script> <script>
import { onMount } from 'svelte';
import { navigate } from '../lib/routing.js'; import { navigate } from '../lib/routing.js';
export let target; export let target;
@ -9,6 +7,9 @@
export let alt = false; export let alt = false;
export let shift = false; export let shift = false;
let classes = "";
export {classes as class};
function click() { function click() {
if (typeof target === 'string') { if (typeof target === 'string') {
navigate(target); navigate(target);
@ -28,14 +29,15 @@
if (alt && !event.altKey) return; if (alt && !event.altKey) return;
if (shift && !event.shiftKey) return; if (shift && !event.shiftKey) return;
if (event.code === hotkey) click(); if (event.key === hotkey) {
if (hotkey === 'Enter' && event.code === 'NumpadEnter') click(); click();
}
} }
</script> </script>
<svelte:window on:keydown={handleHotkey} /> <svelte:window on:keydown={handleHotkey} />
<a href="#" on:click="{click}"> <a href="/{target}" on:click|preventDefault="{click}" class={classes}>
<slot></slot> <slot></slot>
</a> </a>

View File

@ -1,21 +1,27 @@
<script> <script>
import Link from './Link.svelte'; import Link from './Link.svelte';
import Icon from './Icon.svelte'; import Icon from './Icon.svelte';
export let position = "sticky";
</script> </script>
<nav class="fixed top-0 grid grid-cols-2 w-full p-2"> <nav class="{position} top-0 bg-base-100 w-full flex justify-between items-center p-2">
<div> <div>
<Link target="Home"> <Link target="Home">
<button class="btn btn-squre btn-ghost align-middle"> <button class="btn btn-square btn-ghost align-middle">
<Icon name="home" class="w-8 h-8 stroke-2" /> <Icon name="home" class="w-8 h-8 stroke-2" />
</button> </button>
</Link> </Link>
</div> </div>
<div class="justify-self-end"> {#if $$slots.title}
<slot name="title"></slot>
{/if}
<div>
<Link target="Settings"> <Link target="Settings">
<button class="align-middle btn btn-square btn-ghost"> <button class="btn btn-square btn-ghost align-middle ">
<Icon name="cog-8-tooth" class="w-8 h-8 stroke-2" /> <Icon name="cog-8-tooth" class="w-8 h-8 stroke-2" />
</button> </button>
</Link> </Link>

View File

@ -0,0 +1,80 @@
<script>
import { createEventDispatcher } from 'svelte';
import Setting from './Setting.svelte';
export let title;
export let value;
export let unit = '';
export let min = null;
export let max = null;
export let decimal = false;
const dispatch = createEventDispatcher();
$: localValue = value.toString();
let lastInputTime = null;
function debounce(event) {
lastInputTime = Date.now();
localValue = localValue.replace(/[^-0-9.]/g, '');
const eventTime = lastInputTime;
const pendingValue = localValue;
window.setTimeout(
() => {
// if no other inputs have occured since then
if (eventTime === lastInputTime) {
updateValue(pendingValue);
}
},
500
)
}
let error = null;
function updateValue(newValue) {
// Don't update the value, but also don't error, if it's empty
// or if it could be the start of a negative or decimal number
if (newValue.match(/^$|^-$|^\.$/) !== null) {
error = null;
return;
}
const num = parseFloat(newValue);
if (num % 1 !== 0 && !decimal) {
error = `${num} is not a whole number`;
}
else if (min !== null && num < min) {
error = `Too low (minimum ${min})`;
}
else if (max !== null && num > max) {
error = `Too large (maximum ${max})`
}
else {
error = null;
value = num;
dispatch('update', {value})
}
}
</script>
<Setting {title}>
<div slot="input">
{#if unit}
<span class="mr-2">{unit}:</span>
{/if}
<div class="tooltip tooltip-error" class:tooltip-open={error !== null} data-tip="{error}">
<input
type="text"
class="input input-sm input-bordered text-right"
size="{Math.max(5, localValue.length)}"
class:input-error={error}
bind:value={localValue}
on:input="{debounce}"
/>
</div>
</div>
<slot name="description" slot="description"></slot>
</Setting>

View File

@ -0,0 +1,19 @@
<script>
import { slide } from 'svelte/transition';
import ErrorAlert from '../ErrorAlert.svelte';
export let title;
</script>
<div class="divider"></div>
<div class="flex justify-between">
<h3 class="text-lg font-bold">{title}</h3>
<slot name="input"></slot>
</div>
{#if $$slots.description}
<p class="mt-3">
<slot name="description"></slot>
</p>
{/if}

View File

@ -0,0 +1,22 @@
<script>
import { createEventDispatcher } from 'svelte';
import Setting from './Setting.svelte';
export let title;
export let value;
const dispatch = createEventDispatcher();
</script>
<Setting {title}>
<input
slot="input"
type="checkbox"
class="toggle toggle-success"
bind:checked={value}
on:change={e => dispatch('update', {value: e.target.checked})}
/>
<slot name="description" slot="description"></slot>
</Setting>

3
src/ui/settings/index.js Normal file
View File

@ -0,0 +1,3 @@
export { default as Setting } from './Setting.svelte';
export { default as ToggleSetting } from './ToggleSetting.svelte';
export { default as NumericSetting } from './NumericSetting.svelte';

View File

@ -1,16 +1,35 @@
<script> <script>
import { onMount } from 'svelte';
import { invoke } from '@tauri-apps/api/tauri'; import { invoke } from '@tauri-apps/api/tauri';
import { navigate } from '../lib/routing.js'; import { navigate } from '../lib/routing.js';
import { appState } from '../lib/state.js'; import { appState, completeRequest } from '../lib/state.js';
import ErrorAlert from '../ui/ErrorAlert.svelte';
import Link from '../ui/Link.svelte'; import Link from '../ui/Link.svelte';
import Icon from '../ui/Icon.svelte';
// Send response to backend, display error if applicable
let error, alert;
async function respond() {
let {id, approval} = $appState.currentRequest;
try {
await invoke('respond', {response: {id, approval}});
navigate('ShowResponse');
}
catch (e) {
if (error) {
alert.shake();
}
error = e;
}
}
// Approval has one of several outcomes depending on current credential state
async function approve() { async function approve() {
$appState.currentRequest.approval = 'Approved';
let status = await invoke('get_session_status'); let status = await invoke('get_session_status');
if (status === 'unlocked') { if (status === 'unlocked') {
navigate('ShowApproved'); await respond();
} }
else if (status === 'locked') { else if (status === 'locked') {
navigate('Unlock'); navigate('Unlock');
@ -20,34 +39,69 @@
} }
} }
var appName = null; // Denial has only one
async function deny() {
$appState.currentRequest.approval = 'Denied';
await respond();
}
// Extract executable name from full path
let appName = null;
if ($appState.currentRequest.clients.length === 1) { if ($appState.currentRequest.clients.length === 1) {
let path = $appState.currentRequest.clients[0].exe; let path = $appState.currentRequest.clients[0].exe;
let m = path.match(/\/([^/]+?$)|\\([^\\]+?$)/); let m = path.match(/\/([^/]+?$)|\\([^\\]+?$)/);
appName = m[1] || m[2]; appName = m[1] || m[2];
} }
// Executable paths can be long, so ensure they only break on \ or /
function breakPath(client) {
return client.exe.replace(/(\\|\/)/g, '$1<wbr>');
}
// if the request has already been approved/denied, send response immediately
onMount(async () => {
if ($appState.currentRequest.approval) {
await respond();
}
})
</script> </script>
<div class="flex flex-col space-y-4 p-4 m-auto max-w-max h-screen justify-center"> <!-- Don't render at all if we're just going to immediately proceed to the next screen -->
<!-- <div class="p-4 rounded-box border-2 border-neutral-content"> --> {#if !$appState.currentRequest.approval}
<div class="flex flex-col space-y-4 p-4 m-auto max-w-xl h-screen items-center justify-center">
{#if error}
<ErrorAlert bind:this={alert}>
{error}
<svelte:fragment slot="buttons">
<button class="btn btn-sm btn-alert-error" on:click={completeRequest}>Cancel</button>
<button class="btn btn-sm btn-alert-error" on:click={respond}>Retry</button>
</svelte:fragment>
</ErrorAlert>
{/if}
<div class="space-y-1 mb-4"> <div class="space-y-1 mb-4">
<h2 class="text-xl font-bold">{appName ? `"${appName}"` : 'An appplication'} would like to access your AWS credentials.</h2> <h2 class="text-xl font-bold">{appName ? `"${appName}"` : 'An appplication'} would like to access your AWS credentials.</h2>
<div class="grid grid-cols-[auto_1fr] gap-x-3">
{#each $appState.currentRequest.clients as client} {#each $appState.currentRequest.clients as client}
<p>Path: {client ? client.exe : 'Unknown'}</p> <div class="text-right">Path:</div>
<p>PID: {client ? client.pid : 'Unknown'}</p> <code class="">{@html client ? breakPath(client) : 'Unknown'}</code>
<div class="text-right">PID:</div>
<code>{client ? client.pid : 'Unknown'}</code>
{/each} {/each}
</div> </div>
</div>
<div class="grid grid-cols-2"> <div class="w-full flex justify-between">
<Link target="ShowDenied" hotkey="Escape"> <Link target={deny} hotkey="Escape">
<button class="btn btn-error justify-self-start"> <button class="btn btn-error justify-self-start">
Deny Deny
<kbd class="ml-2 normal-case px-1 py-0.5 rounded border border-neutral">Esc</kbd> <kbd class="ml-2 normal-case px-1 py-0.5 rounded border border-neutral">Esc</kbd>
</button> </button>
</Link> </Link>
<Link target="{approve}" hotkey="Enter" shift="{true}"> <Link target={approve} hotkey="Enter" shift="{true}">
<button class="btn btn-success justify-self-end"> <button class="btn btn-success justify-self-end">
Approve Approve
<kbd class="ml-2 normal-case px-1 py-0.5 rounded border border-neutral">Shift</kbd> <kbd class="ml-2 normal-case px-1 py-0.5 rounded border border-neutral">Shift</kbd>
@ -56,4 +110,5 @@
</button> </button>
</Link> </Link>
</div> </div>
</div> </div>
{/if}

View File

@ -11,16 +11,25 @@
let errorMsg = null; let errorMsg = null;
let alert; let alert;
let AccessKeyId, SecretAccessKey, passphrase let AccessKeyId, SecretAccessKey, passphrase, confirmPassphrase
function confirm() {
if (passphrase !== confirmPassphrase) {
errorMsg = 'Passphrases do not match.'
}
}
async function save() { async function save() {
console.log('Saving credentials.'); if (passphrase !== confirmPassphrase) {
let credentials = {AccessKeyId, SecretAccessKey}; alert.shake();
return;
}
let credentials = {AccessKeyId, SecretAccessKey};
try { try {
await invoke('save_credentials', {credentials, passphrase}); await invoke('save_credentials', {credentials, passphrase});
if ($appState.currentRequest) { if ($appState.currentRequest) {
navigate('ShowApproved'); navigate('Approve');
} }
else { else {
navigate('Home'); navigate('Home');
@ -54,6 +63,7 @@
<input type="text" placeholder="AWS Access Key ID" bind:value="{AccessKeyId}" class="input input-bordered" /> <input type="text" placeholder="AWS Access Key ID" bind:value="{AccessKeyId}" class="input input-bordered" />
<input type="password" placeholder="AWS Secret Access Key" bind:value="{SecretAccessKey}" class="input input-bordered" /> <input type="password" placeholder="AWS Secret Access Key" bind:value="{SecretAccessKey}" class="input input-bordered" />
<input type="password" placeholder="Passphrase" bind:value="{passphrase}" class="input input-bordered" /> <input type="password" placeholder="Passphrase" bind:value="{passphrase}" class="input input-bordered" />
<input type="password" placeholder="Re-enter passphrase" bind:value={confirmPassphrase} class="input input-bordered" on:change={confirm} />
<input type="submit" class="btn btn-primary" /> <input type="submit" class="btn btn-primary" />
<Link target="Home" hotkey="Escape"> <Link target="Home" hotkey="Escape">

View File

@ -8,40 +8,42 @@
import Icon from '../ui/Icon.svelte'; import Icon from '../ui/Icon.svelte';
import Link from '../ui/Link.svelte'; import Link from '../ui/Link.svelte';
import vaultDoorSvg from '../assets/vault_door.svg?raw';
onMount(async () => {
// will block until a request comes in
let req = await $appState.pendingRequests.get();
$appState.currentRequest = req;
navigate('Approve');
});
let status = 'unknown'; // onMount(async () => {
onMount(async() => { // // will block until a request comes in
status = await invoke('get_session_status'); // let req = await $appState.pendingRequests.get();
}) // $appState.currentRequest = req;
// navigate('Approve');
// });
</script> </script>
<Nav /> <Nav position="fixed">
<h2 slot="title" class="text-3xl font-bold">Creddy</h2>
</Nav>
{#if status === 'locked'} <div class="flex flex-col h-screen items-center justify-center p-4 space-y-4">
<div class="flex flex-col h-screen justify-center items-center space-y-4"> {#await invoke('get_session_status') then status}
<img src="/static/padlock-closed.svg" alt="An unlocked padlock" class="w-32" /> {#if status === 'locked'}
{@html vaultDoorSvg}
<h2 class="text-2xl font-bold">Creddy is locked</h2> <h2 class="text-2xl font-bold">Creddy is locked</h2>
<Link target="Unlock"> <Link target="Unlock" hotkey="Enter" class="w-64">
<button class="btn btn-primary">Unlock</button> <button class="btn btn-primary w-full">Unlock</button>
</Link> </Link>
</div>
{:else if status === 'unlocked'} {:else if status === 'unlocked'}
<div class="flex flex-col h-screen justify-center items-center space-y-4"> {@html vaultDoorSvg}
<img src="/static/padlock-open.svg" alt="An unlocked padlock" class="w-24" />
<h2 class="text-2xl font-bold">Waiting for requests</h2> <h2 class="text-2xl font-bold">Waiting for requests</h2>
</div>
{:else if status === 'empty'} {:else if status === 'empty'}
<Link target="EnterCredentials"> {@html vaultDoorSvg}
<button class="btn btn-primary">Enter Credentials</button> <h2 class="text-2xl font-bold">No credentials found</h2>
<Link target="EnterCredentials" hotkey="Enter" class="w-64">
<button class="btn btn-primary w-full">Enter Credentials</button>
</Link> </Link>
{/if} {/if}
{/await}
</div>

View File

@ -1,44 +1,94 @@
<script> <script>
import { invoke } from '@tauri-apps/api/tauri';
import { type } from '@tauri-apps/api/os';
import { appState } from '../lib/state.js';
import Nav from '../ui/Nav.svelte'; import Nav from '../ui/Nav.svelte';
import Link from '../ui/Link.svelte'; import Link from '../ui/Link.svelte';
import ErrorAlert from '../ui/ErrorAlert.svelte';
import { Setting, ToggleSetting, NumericSetting } from '../ui/settings';
import { fly } from 'svelte/transition';
import { backInOut } from 'svelte/easing';
let error = null;
async function save() {
try {
await invoke('save_config', {config: $appState.config});
}
catch (e) {
error = e;
$appState.config = await invoke('get_config');
}
}
let osType = '';
type().then(t => osType = t);
</script> </script>
<Nav /> <Nav>
<h2 slot="title" class="text-2xl font-bold">Settings</h2>
</Nav>
<div class="mx-auto mt-3 max-w-md"> {#await invoke('get_config') then config}
<h2 class="text-2xl font-bold text-center">Settings</h2> <div class="max-w-md mx-auto mt-1.5 p-4">
<!-- <h2 class="text-2xl font-bold text-center">Settings</h2> -->
<div class="divider"></div> <ToggleSetting title="Start on login" bind:value={$appState.config.start_on_login} on:update={save}>
<div class="grid grid-cols-2 items-center"> <svelte:fragment slot="description">
<h3 class="text-lg font-bold">Start minimized</h3> Start Creddy when you log in to your computer.
<input type="checkbox" class="justify-self-end toggle toggle-success" /> </svelte:fragment>
</div> </ToggleSetting>
<p class="mt-3">Minimize to the system tray at startup.</p>
<div class="divider"></div> <ToggleSetting title="Start minimized" bind:value={$appState.config.start_minimized} on:update={save}>
<div class="grid grid-cols-2 items-center"> <svelte:fragment slot="description">
<h3 class="text-lg font-bold">Re-hide delay</h3> Minimize to the system tray at startup.
<div class="justify-self-end"> </svelte:fragment>
<span class="mr-2">(Seconds)</span> </ToggleSetting>
<input type="text" class="input input-sm input-bordered text-right max-w-[4rem]" />
</div> <NumericSetting title="Re-hide delay" bind:value={$appState.config.rehide_ms} min={0} unit="Milliseconds" on:update={save}>
</div> <svelte:fragment slot="description">
<p class="mt-3">
How long to wait after a request is approved/denied before minimizing How long to wait after a request is approved/denied before minimizing
the window to tray. Only applicable if the window was minimized the window to tray. Only applicable if the window was minimized
to tray before the request was received. to tray before the request was received.
</p> </svelte:fragment>
</NumericSetting>
<div class="divider"></div> <NumericSetting
<div class="grid grid-cols-2 items-center"> title="Listen port"
<h3 class="text-lg font-bold">Update credentials</h3> bind:value={$appState.config.listen_port}
<div class="justify-self-end"> min={osType === 'Windows_NT' ? 1 : 0}
<Link target="EnterCredentials"> on:update={save}
>
<svelte:fragment slot="description">
Listen for credentials requests on this port.
(Should be used with <code>$AWS_CONTAINER_CREDENTIALS_FULL_URI</code>)
</svelte:fragment>
</NumericSetting>
<Setting title="Update credentials">
<Link slot="input" target="EnterCredentials">
<button class="btn btn-sm btn-primary">Update</button> <button class="btn btn-sm btn-primary">Update</button>
</Link> </Link>
<svelte:fragment slot="description">
Update or re-enter your encrypted credentials.
</svelte:fragment>
</Setting>
</div> </div>
</div> {/await}
<p class="mt-3">Update or re-enter your encrypted credentials.</p>
</div> {#if error}
<div transition:fly={{y: 100, easing: backInOut, duration: 400}} class="toast">
<div class="alert alert-error no-animation">
<div>
<span>{error}</span>
</div>
<div>
<button class="btn btn-sm btn-alert-error" on:click={() => error = null}>Ok</button>
</div>
</div>
</div>
{/if}

View File

@ -1,76 +0,0 @@
<script>
import { onMount } from 'svelte';
import { draw, fade } from 'svelte/transition';
import { emit } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/tauri';
import { appState } from '../lib/state.js';
import { navigate } from '../lib/routing.js';
import ErrorAlert from '../ui/ErrorAlert.svelte';
import Icon from '../ui/Icon.svelte';
import Link from '../ui/Link.svelte';
let success = false;
let error = null;
async function respond() {
let response = {
id: $appState.currentRequest.id,
approval: 'Approved',
};
try {
await invoke('respond', {response});
success = true;
$appState.currentRequest = null;
window.setTimeout(() => navigate('Home'), 1000);
}
catch (e) {
error = e;
}
}
onMount(respond);
</script>
<style>
:global(body) {
overflow: hidden;
}
</style>
{#if error}
<div class="flex flex-col h-screen items-center justify-center m-auto max-w-lg">
<ErrorAlert>
{error}
<Link target="Home">
<button
slot="buttons"
class="btn btn-sm bg-transparent hover:bg-[#cd5a5a] border border-error-content text-error-content"
>
Ok
</button>
</Link>
</ErrorAlert>
</div>
{:else if success}
<div class="flex flex-col h-screen items-center justify-center max-w-max m-auto">
<svg xmlns="http://www.w3.org/2000/svg" class="w-36 h-36" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor">
<path in:draw="{{duration: 500}}" stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div in:fade="{{delay: 200, duration: 300}}" class="text-2xl font-bold">Approved!</div>
</div>
{/if}
<!--
{#if error}
<div class="text-red-400">{error}</div>
{:else}
<h1 class="text-4xl text-gray-300">Approved!</h1>
{/if}
-->

View File

@ -1,57 +0,0 @@
<script>
import { onMount } from 'svelte';
import { draw, fade } from 'svelte/transition';
import { emit } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/tauri';
import { appState } from '../lib/state.js';
import { navigate } from '../lib/routing.js';
import ErrorAlert from '../ui/ErrorAlert.svelte';
import Icon from '../ui/Icon.svelte';
import Link from '../ui/Link.svelte';
let error = null;
async function respond() {
let response = {
id: $appState.currentRequest.id,
approval: 'Denied',
}
try {
await invoke('respond', {response});
$appState.currentRequest = null;
window.setTimeout(() => navigate('Home'), 1000);
}
catch (e) {
error = e;
}
}
onMount(respond);
</script>
{#if error}
<div class="flex flex-col h-screen items-center justify-center m-auto max-w-lg">
<ErrorAlert>
{error}
<Link target="Home">
<button
slot="buttons"
class="btn btn-sm bg-transparent hover:bg-[#cd5a5a] border border-error-content text-error-content"
>
Ok
</button>
</Link>
</ErrorAlert>
</div>
{:else}
<div class="flex flex-col items-center justify-center h-screen max-w-max m-auto">
<svg xmlns="http://www.w3.org/2000/svg" class="w-36 h-36" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor">
<path in:draw="{{duration: 500}}" stroke-linecap="round" stroke-linejoin="round" d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div in:fade="{{delay: 200, duration: 300}}" class="text-2xl font-bold">Denied!</div>
</div>
{/if}

View File

@ -0,0 +1,38 @@
<script>
import { onMount } from 'svelte';
import { draw, fade } from 'svelte/transition';
import { appState, completeRequest } from '../lib/state.js';
let success = false;
let error = null;
let drawDuration = $appState.config.rehide_ms >= 750 ? 500 : 0;
let fadeDuration = drawDuration * 0.6;
let fadeDelay = drawDuration * 0.4;
onMount(() => {
window.setTimeout(
completeRequest,
// Extra 50ms so the window can finish disappearing before the redraw
Math.min(5000, $appState.config.rehide_ms + 50),
)
})
</script>
<div class="flex flex-col h-screen items-center justify-center max-w-max m-auto">
{#if $appState.currentRequest.approval === 'Approved'}
<svg xmlns="http://www.w3.org/2000/svg" class="w-36 h-36" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor">
<path in:draw="{{duration: drawDuration}}" stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" class="w-36 h-36" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor">
<path in:draw="{{duration: 500}}" stroke-linecap="round" stroke-linejoin="round" d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{/if}
<div in:fade="{{duration: fadeDuration, delay: fadeDelay}}" class="text-2xl font-bold">
{$appState.currentRequest.approval}!
</div>
</div>

View File

@ -1,5 +1,6 @@
<script> <script>
import { invoke } from '@tauri-apps/api/tauri'; import { invoke } from '@tauri-apps/api/tauri';
import { onMount } from 'svelte';
import { appState } from '../lib/state.js'; import { appState } from '../lib/state.js';
import { navigate } from '../lib/routing.js'; import { navigate } from '../lib/routing.js';
@ -11,12 +12,19 @@
let errorMsg = null; let errorMsg = null;
let alert; let alert;
let passphrase = ''; let passphrase = '';
let loadTime = 0;
async function unlock() { async function unlock() {
// The hotkey for navigating here from homepage is Enter, which also
// happens to trigger the form submit event
if (Date.now() - loadTime < 10) {
return;
}
try { try {
let r = await invoke('unlock', {passphrase}); let r = await invoke('unlock', {passphrase});
$appState.credentialStatus = 'unlocked'; $appState.credentialStatus = 'unlocked';
if ($appState.currentRequest) { if ($appState.currentRequest) {
navigate('ShowApproved'); navigate('Approve');
} }
else { else {
navigate('Home'); navigate('Home');
@ -37,6 +45,10 @@
} }
} }
} }
onMount(() => {
loadTime = Date.now();
})
</script> </script>
@ -47,6 +59,7 @@
<ErrorAlert bind:this="{alert}">{errorMsg}</ErrorAlert> <ErrorAlert bind:this="{alert}">{errorMsg}</ErrorAlert>
{/if} {/if}
<!-- svelte-ignore a11y-autofocus -->
<input autofocus name="password" type="password" placeholder="correct horse battery staple" bind:value="{passphrase}" class="input input-bordered" /> <input autofocus name="password" type="password" placeholder="correct horse battery staple" bind:value="{passphrase}" class="input input-bordered" />
<input type="submit" class="btn btn-primary" /> <input type="submit" class="btn btn-primary" />

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 34 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 46 KiB