feat(otp): implement OTP management and integration with UI components

This commit is contained in:
Kang committed 2026-04-10 20:45:08 +08:00
1 parent 484bf886ba
commit a3aa537dc1
20 files changed
+1598 -22

No files matched your search

+168 -4
View File
@@ -115,6 +115,12 @@ dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "android_system_properties"
version = "0.1.5"
@@ -521,6 +527,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.11.1"
@@ -747,6 +759,12 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746"
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "combine"
version = "4.6.7"
@@ -1351,6 +1369,7 @@ dependencies = [
"font-kit",
"hex",
"hmac 0.12.1",
"image",
"lazy_static",
"md-5",
"notify",
@@ -1358,6 +1377,7 @@ dependencies = [
"portable-pty",
"rand 0.8.5",
"reqwest",
"rqrr",
"russh",
"russh-sftp",
"serde",
@@ -1948,6 +1968,34 @@ dependencies = [
"byteorder",
]
[[package]]
name = "g2gen"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5a7e0eb46f83a20260b850117d204366674e85d3a908d90865c78df9a6b1dfc"
dependencies = [
"g2poly",
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "g2p"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "539e2644c030d3bf4cd208cb842d2ce2f80e82e6e8472390bcef83ceba0d80ad"
dependencies = [
"g2gen",
"g2poly",
]
[[package]]
name = "g2poly"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "312d2295c7302019c395cfb90dacd00a82a2eabd700429bba9c7a3f38dbbe11b"
[[package]]
name = "gdk"
version = "0.18.2"
@@ -2142,6 +2190,16 @@ dependencies = [
"polyval 0.7.1",
]
[[package]]
name = "gif"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
dependencies = [
"color_quant",
"weezl",
]
[[package]]
name = "gio"
version = "0.18.4"
@@ -2332,6 +2390,8 @@ version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
@@ -2555,7 +2615,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
dependencies = [
"byteorder",
"png",
"png 0.17.16",
]
[[package]]
@@ -2672,6 +2732,34 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"color_quant",
"gif",
"image-webp",
"moxcms",
"num-traits",
"png 0.18.1",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -3089,6 +3177,15 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
dependencies = [
"hashbrown 0.15.5",
]
[[package]]
name = "lru-slab"
version = "0.1.2"
@@ -3242,6 +3339,16 @@ dependencies = [
"num-traits",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "muda"
version = "0.17.1"
@@ -3257,7 +3364,7 @@ dependencies = [
"objc2-core-foundation",
"objc2-foundation",
"once_cell",
"png",
"png 0.17.16",
"serde",
"thiserror 2.0.18",
"windows-sys 0.60.2",
@@ -4211,6 +4318,19 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags 2.10.0",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]]
name = "polling"
version = "3.11.0"
@@ -4426,6 +4546,18 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "pxfm"
version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.38.4"
@@ -4810,6 +4942,17 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rqrr"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2260da7f69877ba68c49a0c2d9946829848236c708dd40d2a6baf8c868ee887"
dependencies = [
"g2p",
"image",
"lru",
]
[[package]]
name = "rsa"
version = "0.9.10"
@@ -6046,7 +6189,7 @@ dependencies = [
"ico",
"json-patch",
"plist",
"png",
"png 0.17.16",
"proc-macro2",
"quote",
"semver",
@@ -6698,7 +6841,7 @@ dependencies = [
"objc2-core-graphics",
"objc2-foundation",
"once_cell",
"png",
"png 0.17.16",
"serde",
"thiserror 2.0.18",
"windows-sys 0.60.2",
@@ -7187,6 +7330,12 @@ dependencies = [
"windows-core 0.61.2",
]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "winapi"
version = "0.3.9"
@@ -8157,6 +8306,21 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "zune-core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
[[package]]
name = "zvariant"
version = "5.9.2"
+2
View File
@@ -74,6 +74,8 @@ hex = "0.4"
urlencoding = "2"
zip = "8"
encoding_rs = "0.8"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "gif", "webp"] }
rqrr = "0.9"
[profile.release]
lto = true
+5
View File
@@ -239,6 +239,11 @@ impl Totp {
self.hotp.secret()
}
#[inline]
pub fn period(&self) -> u64 {
self.period
}
/// Parses a TOTP configuration from a URI string in the [Key URI Format].
///
/// This function supports URIs of the form:
+1
View File
@@ -1,5 +1,6 @@
pub mod connection;
pub mod importer;
pub mod otp;
pub mod proxy;
pub mod session;
pub mod settings;
+191
View File
@@ -0,0 +1,191 @@
use crate::config;
use crate::config::OtpEntry;
use crate::error::AppResult;
use crate::utils::crypto;
use serde::Serialize;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OtpCodeResult {
pub code: String,
pub remaining_seconds: u64,
}
#[tauri::command]
pub fn get_otp_entries(app: tauri::AppHandle) -> AppResult<Vec<OtpEntry>> {
let mut cfg = config::load_otp_entries(&app)?;
for entry in &mut cfg.entries {
entry.secret = None;
}
Ok(cfg.entries)
}
#[tauri::command]
pub fn get_otp_secret_value(app: tauri::AppHandle, id: String) -> AppResult<Option<String>> {
Ok(config::load_otp_entry_by_id(&app, &id)?.secret)
}
#[tauri::command]
pub fn save_otp_entry(app: tauri::AppHandle, mut entry: OtpEntry) -> AppResult<String> {
let mut cfg = config::load_otp_entries(&app)?;
if entry.id.is_empty() {
entry.id = uuid::Uuid::new_v4().to_string();
}
let target_id = entry.id.clone();
let existing = cfg.entries.iter().find(|e| e.id == target_id);
entry.secret = match entry.secret.as_deref() {
Some(plain) if !plain.is_empty() => Some(crypto::encrypt(plain)?),
_ => existing.and_then(|e| e.secret.clone()),
};
if let Some(ex) = cfg.entries.iter_mut().find(|e| e.id == target_id) {
*ex = entry;
} else {
cfg.entries.push(entry);
}
config::save_otp_entries(&app, &cfg)?;
Ok(target_id)
}
#[tauri::command]
pub fn delete_otp_entry(app: tauri::AppHandle, id: String) -> AppResult<()> {
let mut cfg = config::load_otp_entries(&app)?;
cfg.entries.retain(|e| e.id != id);
config::save_otp_entries(&app, &cfg)
}
#[tauri::command]
pub fn generate_otp_code(app: tauri::AppHandle, id: String) -> AppResult<OtpCodeResult> {
generate_otp_for_entry(&app, &id)
}
#[tauri::command]
pub fn import_otp_from_qr(path: String) -> AppResult<OtpEntry> {
use crate::error::AppError;
let img = image::open(&path)
.map_err(|e| AppError::Config(format!("Failed to open image: {e}")))?;
let gray = img.to_luma8();
let mut prepared = rqrr::PreparedImage::prepare(gray);
let grids = prepared.detect_grids();
let grid = grids
.first()
.ok_or_else(|| AppError::Config("No QR code found in the image".to_string()))?;
let (_, uri) = grid
.decode()
.map_err(|e| AppError::Config(format!("Failed to decode QR code: {e}")))?;
if uri.starts_with("otpauth://totp/") {
let totp = dragonfly_otp::Totp::from_uri(&uri)
.map_err(|e| AppError::Config(format!("Invalid TOTP URI: {e}")))?;
Ok(OtpEntry {
id: String::new(),
otp_type: "totp".to_string(),
issuer: totp.issuer().to_string(),
username: totp.label().to_string(),
secret: Some(totp.secret().into_base32()),
algorithm: totp.alg().to_string(),
digits: totp.digits(),
period: totp.period(),
counter: 0,
has_secret: false,
})
} else if uri.starts_with("otpauth://hotp/") {
let hotp = dragonfly_otp::Hotp::from_uri(&uri)
.map_err(|e| AppError::Config(format!("Invalid HOTP URI: {e}")))?;
Ok(OtpEntry {
id: String::new(),
otp_type: "hotp".to_string(),
issuer: hotp.issuer().to_string(),
username: hotp.label().to_string(),
secret: Some(hotp.secret().into_base32()),
algorithm: hotp.alg().to_string(),
digits: hotp.digits(),
period: 30,
counter: hotp.counter(),
has_secret: false,
})
} else {
Err(AppError::Config(format!(
"QR code does not contain a valid otpauth:// URI: {uri}"
)))
}
}
/// Shared logic for generating an OTP code from a stored entry.
pub(crate) fn generate_otp_for_entry(
app: &tauri::AppHandle,
id: &str,
) -> AppResult<OtpCodeResult> {
let entry = config::load_otp_entry_by_id(app, id)?;
let secret_str = entry
.secret
.as_deref()
.ok_or_else(|| crate::error::AppError::Config("OTP entry has no secret".to_string()))?;
let alg = match entry.algorithm.as_str() {
"SHA256" => dragonfly_otp::Algorithm::SHA256,
"SHA512" => dragonfly_otp::Algorithm::SHA512,
_ => dragonfly_otp::Algorithm::SHA1,
};
let secret = dragonfly_otp::Secret::from_base32(secret_str)
.map_err(|e| crate::error::AppError::Config(format!("Invalid base32 secret: {e:?}")))?;
match entry.otp_type.as_str() {
"hotp" => {
let mut hotp = dragonfly_otp::Hotp::new(
alg,
entry.issuer.clone(),
entry.username.clone(),
entry.digits,
entry.counter,
secret,
);
let raw = hotp.generate();
let code = format!("{:0>width$}", raw, width = entry.digits as usize);
// Increment the counter and persist
let mut cfg = config::load_otp_entries(app)?;
if let Some(e) = cfg.entries.iter_mut().find(|e| e.id == id) {
e.counter += 1;
}
config::save_otp_entries(app, &cfg)?;
Ok(OtpCodeResult {
code,
remaining_seconds: 0,
})
}
_ => {
let period = if entry.period > 0 { entry.period } else { 30 };
let totp = dragonfly_otp::Totp::new(
alg,
entry.issuer.clone(),
entry.username.clone(),
entry.digits,
period,
secret,
);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock before UNIX epoch")
.as_secs();
let raw = totp.generate_at(now);
let code = format!("{:0>width$}", raw, width = entry.digits as usize);
let remaining = period - (now % period);
Ok(OtpCodeResult {
code,
remaining_seconds: remaining,
})
}
}
}
+8
View File
@@ -51,6 +51,14 @@ pub struct SavedConnection {
#[serde(default)]
pub proxy_id: Option<String>,
/// References an OTP entry for two-factor authentication.
#[serde(default)]
pub otp_id: Option<String>,
/// When true, auto-fill the OTP code during keyboard-interactive auth.
#[serde(default)]
pub auto_fill_otp: bool,
#[serde(default)]
pub network: ConnectionNetworkSettings,
}
+3
View File
@@ -4,6 +4,7 @@
mod connection;
mod key;
mod otp;
mod password;
mod proxy;
mod quick_command;
@@ -23,6 +24,8 @@ pub use password::{
load_password_by_id, load_passwords, save_passwords, PasswordsConfig, SavedPassword,
};
#[allow(unused_imports)]
pub use otp::{load_otp_entries, load_otp_entry_by_id, save_otp_entries, OtpConfig, OtpEntry};
#[allow(unused_imports)]
pub use proxy::{load_proxies, load_proxy_by_id, save_proxies, ProxyConfig};
#[allow(unused_imports)]
pub use quick_command::{
+78
View File
@@ -0,0 +1,78 @@
use super::{get_config_dir, load_json, save_json, uuid_v4};
use crate::error::{AppError, AppResult};
use crate::utils::crypto;
use serde::{Deserialize, Serialize};
use tauri::AppHandle;
fn default_digits() -> u8 {
6
}
fn default_period() -> u64 {
30
}
/// Stored OTP entry. The secret is AES-256-GCM encrypted on disk.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OtpEntry {
#[serde(default = "uuid_v4")]
pub id: String,
/// `"totp"` or `"hotp"`.
pub otp_type: String,
pub issuer: String,
pub username: String,
/// Encrypted secret on disk; plaintext only after explicit decryption.
#[serde(default)]
pub secret: Option<String>,
#[serde(default = "default_algorithm")]
pub algorithm: String,
#[serde(default = "default_digits")]
pub digits: u8,
/// Time step in seconds (TOTP only).
#[serde(default = "default_period")]
pub period: u64,
/// Counter value (HOTP only).
#[serde(default)]
pub counter: u64,
/// Transient: true when encrypted secret data exists on disk.
#[serde(default, skip_serializing)]
pub has_secret: bool,
}
fn default_algorithm() -> String {
"SHA1".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct OtpConfig {
#[serde(default)]
pub entries: Vec<OtpEntry>,
}
pub fn load_otp_entries(app: &AppHandle) -> AppResult<OtpConfig> {
let dir = get_config_dir(app)?;
let path = dir.join("otp.json");
let mut config: OtpConfig = load_json(&path)?;
for entry in &mut config.entries {
entry.has_secret = entry.secret.is_some();
}
Ok(config)
}
pub fn save_otp_entries(app: &AppHandle, config: &OtpConfig) -> AppResult<()> {
let dir = get_config_dir(app)?;
save_json(&dir.join("otp.json"), config)
}
pub fn load_otp_entry_by_id(app: &AppHandle, id: &str) -> AppResult<OtpEntry> {
let cfg = load_otp_entries(app)?;
let mut entry = cfg
.entries
.into_iter()
.find(|e| e.id == id)
.ok_or_else(|| AppError::Config(format!("OTP entry '{}' not found", id)))?;
if let Some(ct) = entry.secret.clone() {
entry.secret = crypto::decrypt(&ct).ok();
}
Ok(entry)
}
+2
View File
@@ -427,6 +427,8 @@ pub fn import_sessions(app: tauri::AppHandle, file_path: String) -> AppResult<us
sort_order: 0,
icon: None,
proxy_id: None,
otp_id: None,
auto_fill_otp: false,
network: Default::default(),
});
}
+42 -1
View File
@@ -47,6 +47,7 @@ struct OtpRequestPayload {
request_id: String,
connection_name: String,
prompts: Vec<OtpPrompt>,
otp_entry_id: Option<String>,
}
pub(crate) fn load_saved_ssh_config(app: &AppHandle, connection_id: &str) -> AppResult<SshConfig> {
@@ -123,6 +124,19 @@ pub(super) async fn authenticate_handle(
password_error: &str,
key_error: &str,
) -> AppResult<()> {
authenticate_handle_with_otp(handle, config, app, password_error, key_error, None).await
}
pub(super) async fn authenticate_handle_with_otp(
handle: &mut client::Handle<SshHandler>,
config: &SshConfig,
app: &AppHandle,
password_error: &str,
key_error: &str,
connection_id: Option<&str>,
) -> AppResult<()> {
let otp_info = connection_id.and_then(|cid| resolve_otp_info(app, cid));
match &config.auth {
SshAuth::Password { password } => {
let authenticated = handle
@@ -137,6 +151,7 @@ pub(super) async fn authenticate_handle(
&config.name,
app,
password_error,
otp_info.as_ref(),
)
.await?;
}
@@ -166,6 +181,7 @@ pub(super) async fn authenticate_handle(
&config.name,
app,
key_error,
otp_info.as_ref(),
)
.await?;
}
@@ -174,14 +190,32 @@ pub(super) async fn authenticate_handle(
Ok(())
}
struct OtpAutoFillInfo {
otp_id: String,
auto_fill: bool,
}
fn resolve_otp_info(app: &AppHandle, connection_id: &str) -> Option<OtpAutoFillInfo> {
let conn = crate::config::load_connection_by_id(app, connection_id).ok()?;
let otp_id = conn.otp_id?;
Some(OtpAutoFillInfo {
otp_id,
auto_fill: conn.auto_fill_otp,
})
}
/// Runs the keyboard-interactive auth state machine, emitting `otp-request` events
/// to the frontend for each `InfoRequest` that contains prompts, and automatically
/// responding with an empty array for empty `InfoRequest`s.
///
/// When `otp_info` is present with `auto_fill == true`, the OTP code is generated
/// automatically and used as the response without prompting the user.
async fn finish_keyboard_interactive(
handle: &mut client::Handle<SshHandler>,
username: &str,
connection_name: &str,
app: &AppHandle,
otp_info: Option<&OtpAutoFillInfo>,
) -> AppResult<()> {
let pending_mgr = app
.try_state::<Arc<PendingAuthManager>>()
@@ -208,6 +242,11 @@ async fn finish_keyboard_interactive(
} => {
let responses = if prompts.is_empty() {
Vec::new()
} else if let Some(info) = otp_info.filter(|i| i.auto_fill) {
tracing::info!("Auto-filling OTP for keyboard-interactive auth");
let result =
crate::cmd::otp::generate_otp_for_entry(app, &info.otp_id)?;
prompts.iter().map(|_| result.code.clone()).collect()
} else {
let request_id = uuid::Uuid::new_v4().to_string();
let rx = pending_mgr.register(request_id.clone()).await;
@@ -222,6 +261,7 @@ async fn finish_keyboard_interactive(
echo: prompt.echo,
})
.collect(),
otp_entry_id: otp_info.map(|i| i.otp_id.clone()),
};
let _ = app.emit("otp-request", &payload);
@@ -260,6 +300,7 @@ async fn try_keyboard_interactive_after_partial(
connection_name: &str,
app: &AppHandle,
fallback_error: &str,
otp_info: Option<&OtpAutoFillInfo>,
) -> AppResult<()> {
match auth_result {
client::AuthResult::Success => Ok(()),
@@ -271,7 +312,7 @@ async fn try_keyboard_interactive_after_partial(
tracing::info!(
"Primary auth partial success, continuing with keyboard-interactive"
);
finish_keyboard_interactive(handle, username, connection_name, app).await
finish_keyboard_interactive(handle, username, connection_name, app, otp_info).await
} else {
Err(AppError::Auth(fallback_error.to_string()))
}
+3 -2
View File
@@ -1,4 +1,4 @@
use super::auth::{authenticate_handle, load_saved_ssh_config};
use super::auth::{authenticate_handle, authenticate_handle_with_otp, load_saved_ssh_config};
use super::client::{build_client_config, connect_with_proxy, SshConfig, SshHandle, SshHandler};
use super::io::{open_shell_channel, ssh_io_loop};
use crate::error::AppResult;
@@ -56,12 +56,13 @@ pub async fn create_ssh_session(
let mut handle =
connect_with_proxy(&config, Arc::new(build_client_config(&app)), handler).await?;
authenticate_handle(
authenticate_handle_with_otp(
&mut handle,
&config,
&app,
"Authentication failed: invalid credentials",
"Authentication failed: key rejected",
connection_id.as_deref(),
)
.await?;
+6
View File
@@ -95,6 +95,12 @@ pub fn run() {
cmd::proxy::save_proxy,
cmd::proxy::delete_proxy,
cmd::proxy::get_proxy_password,
cmd::otp::get_otp_entries,
cmd::otp::get_otp_secret_value,
cmd::otp::save_otp_entry,
cmd::otp::delete_otp_entry,
cmd::otp::generate_otp_code,
cmd::otp::import_otp_from_qr,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
@@ -1,6 +1,7 @@
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { OtpCodePanel } from "@/components/panel/OtpCodePanel";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -22,6 +23,7 @@ export interface OtpRequest {
requestId: string;
connectionName: string;
prompts: OtpPrompt[];
otpEntryId?: string;
}
interface OtpDialogProps {
@@ -79,6 +81,13 @@ export function OtpDialog({ request, onDone }: OtpDialogProps) {
}
};
const handleSendToInput = (code: string) => {
if (!request) return;
const next = [...responses];
next[0] = code;
setResponses(next);
};
return (
<Dialog
open={!!request}
@@ -88,34 +97,40 @@ export function OtpDialog({ request, onDone }: OtpDialogProps) {
>
<DialogContent className="max-w-sm" onKeyDown={handleKeyDown}>
<DialogHeader>
<DialogTitle className="text-sm">
{t("otp.title")}
</DialogTitle>
<DialogTitle className="text-sm">{t("otp.title")}</DialogTitle>
<DialogDescription className="text-xs">
{t("otp.description", { name: request?.connectionName })}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
{request?.prompts.map((p, i) => (
<div key={i}>
{request?.prompts.map((p, promptIndex) => (
<div key={`${request.requestId}-${p.prompt}-${p.echo ? "echo" : "masked"}`}>
<Label className="text-[0.6875rem] text-muted-foreground">
{p.prompt.replace(/:\s*$/, "")}
</Label>
<Input
ref={i === 0 ? firstInputRef : undefined}
ref={promptIndex === 0 ? firstInputRef : undefined}
className="mt-1 text-xs h-8"
type={p.echo ? "text" : "password"}
autoComplete="one-time-code"
value={responses[i] ?? ""}
value={responses[promptIndex] ?? ""}
onChange={(e) => {
const next = [...responses];
next[i] = e.target.value;
next[promptIndex] = e.target.value;
setResponses(next);
}}
/>
</div>
))}
{request?.otpEntryId && (
<OtpCodePanel
otpEntryId={request.otpEntryId}
onSendToInput={handleSendToInput}
variant="dialog"
/>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
+231
View File
@@ -0,0 +1,231 @@
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { MdCheck, MdContentCopy, MdRefresh } from "react-icons/md";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { useOtpCode } from "@/hooks/useOtpCode";
import type { OtpEntry } from "@/types/global";
interface OtpCodePanelProps {
className?: string;
onSendToInput?: (code: string) => void;
otpEntryId: string;
otpType?: string;
period?: number;
variant?: "dialog" | "list";
}
function formatOtpCode(code: string) {
return code.match(/.{1,3}/g)?.join(" ") ?? code;
}
function LoadingState({ label }: { label: string }) {
return (
<div className="flex min-h-[4.25rem] items-center justify-center gap-2 text-xs text-muted-foreground">
<MdRefresh className="text-sm animate-spin" />
<span>{label}</span>
</div>
);
}
export function OtpCodePanel({
className = "",
onSendToInput,
otpEntryId,
otpType,
period,
variant = "list",
}: OtpCodePanelProps) {
const { t } = useTranslation();
const [entryMeta, setEntryMeta] = useState<Pick<OtpEntry, "otp_type" | "period"> | null>(null);
const [metaLoading, setMetaLoading] = useState(false);
const metaRequestRef = useRef(0);
useEffect(() => {
if (otpType || !otpEntryId) {
setEntryMeta(null);
setMetaLoading(false);
return;
}
const requestId = ++metaRequestRef.current;
setMetaLoading(true);
invoke<OtpEntry[]>("get_otp_entries")
.then((entries) => {
if (metaRequestRef.current !== requestId) return;
const match = entries.find((entry) => entry.id === otpEntryId);
setEntryMeta(match ? { otp_type: match.otp_type, period: match.period } : null);
})
.catch(() => {
if (metaRequestRef.current !== requestId) return;
setEntryMeta(null);
})
.finally(() => {
if (metaRequestRef.current === requestId) {
setMetaLoading(false);
}
});
}, [otpEntryId, otpType]);
const resolvedType = otpType ?? entryMeta?.otp_type;
const resolvedPeriod = period ?? entryMeta?.period ?? 30;
const containerClassName = useMemo(
() =>
[
"rounded-md border border-border/70 bg-background/70 px-3 py-3",
variant === "dialog" ? "space-y-3" : "space-y-2.5",
className,
]
.filter(Boolean)
.join(" "),
[className, variant],
);
const {
code,
copied,
copyCode,
fetchCode,
hasCode,
isHotp,
isTotp,
loading,
progressPercent,
remaining,
} = useOtpCode({
autoStart: resolvedType === "totp",
otpEntryId,
otpType: resolvedType,
period: resolvedPeriod,
});
if (metaLoading || !resolvedType) {
return (
<div className={containerClassName}>
<LoadingState label={t("otp.loadingCode")} />
</div>
);
}
const showGenerateButton = isHotp;
const showProgress = isTotp && hasCode;
const displayCode = hasCode ? formatOtpCode(code) : "--- ---";
const statusText = showProgress
? t("otp.expiresIn", { seconds: remaining })
: isHotp
? t("otp.hotpHint")
: t("otp.loadingCode");
return (
<div className={containerClassName}>
<div className="flex items-center justify-between gap-2">
<Label className="text-[0.6875rem] text-muted-foreground">{t("otp.currentCode")}</Label>
<span className="rounded-full border border-border/70 px-2 py-0.5 text-[0.5625rem] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
{resolvedType.toUpperCase()}
</span>
</div>
{loading && !hasCode ? (
<LoadingState label={t("otp.loadingCode")} />
) : isHotp && !hasCode ? (
<div className="space-y-2">
<p className="text-xs leading-relaxed text-muted-foreground">{t("otp.hotpHint")}</p>
<Button
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={() => void fetchCode()}
disabled={loading}
>
{loading ? (
<MdRefresh className="mr-1 text-[0.875rem] animate-spin" />
) : (
<MdRefresh className="mr-1 text-[0.875rem]" />
)}
{t("otp.generateCode")}
</Button>
</div>
) : (
<>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="font-mono text-lg font-semibold tracking-[0.3em] text-foreground sm:text-xl">
{displayCode}
</div>
<div className="mt-1 text-[0.6875rem] text-muted-foreground">{statusText}</div>
</div>
{showProgress ? (
<div className="shrink-0 text-right">
<div className="text-[1.15rem] font-semibold tabular-nums text-primary">
{remaining}
</div>
<div className="text-[0.625rem] uppercase tracking-[0.16em] text-muted-foreground">
s
</div>
</div>
) : null}
</div>
{showProgress ? (
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all duration-1000 ease-linear"
style={{ width: `${progressPercent}%` }}
/>
</div>
) : null}
<div
className={`flex gap-2 ${variant === "dialog" ? "flex-col sm:flex-row" : "flex-wrap"}`}
>
<Button
variant="outline"
size="sm"
className={variant === "dialog" ? "h-8 text-xs" : "h-7 text-xs"}
onClick={copyCode}
disabled={!hasCode}
>
{copied ? (
<MdCheck className="mr-1 text-[0.875rem]" />
) : (
<MdContentCopy className="mr-1 text-[0.875rem]" />
)}
{copied ? t("otp.copied") : t("otp.copyCode")}
</Button>
{showGenerateButton ? (
<Button
variant="outline"
size="sm"
className={variant === "dialog" ? "h-8 text-xs" : "h-7 text-xs"}
onClick={() => void fetchCode()}
disabled={loading}
>
{loading ? (
<MdRefresh className="mr-1 text-[0.875rem] animate-spin" />
) : (
<MdRefresh className="mr-1 text-[0.875rem]" />
)}
{t("otp.generateCode")}
</Button>
) : null}
{onSendToInput ? (
<Button
size="sm"
className={variant === "dialog" ? "h-8 text-xs sm:ml-auto" : "h-7 text-xs"}
onClick={() => onSendToInput(code)}
disabled={!hasCode}
>
{t("otp.sendToInput")}
</Button>
) : null}
</div>
</>
)}
</div>
);
}
+14 -3
View File
@@ -2,16 +2,21 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import PanelHeader from "@/components/layout/PanelHeader";
import { KeyManagementTab } from "@/components/settings/KeyManagementTab";
import { OtpManagementTab } from "@/components/settings/OtpManagementTab";
import { PasswordManagementTab } from "@/components/settings/PasswordManagementTab";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
type SecurityAuthTab = "keys" | "passwords";
type SecurityAuthTab = "keys" | "passwords" | "otp";
export default function SecurityAuthPanel() {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<SecurityAuthTab>("keys");
const [keyCount, setKeyCount] = useState(0);
const [passwordCount, setPasswordCount] = useState(0);
const [otpCount, setOtpCount] = useState(0);
const displayCount =
activeTab === "keys" ? keyCount : activeTab === "passwords" ? passwordCount : otpCount;
return (
<div className="h-full flex flex-col" style={{ backgroundColor: "var(--df-bg-panel)" }}>
@@ -19,7 +24,7 @@ export default function SecurityAuthPanel() {
title={t("securityAuth.title")}
actions={
<span className="text-[0.6875rem]" style={{ color: "var(--df-text-dimmed)" }}>
{activeTab === "keys" ? keyCount : passwordCount}
{displayCount}
</span>
}
/>
@@ -29,13 +34,16 @@ export default function SecurityAuthPanel() {
onValueChange={(value) => setActiveTab(value as SecurityAuthTab)}
className="w-full"
>
<TabsList className="grid w-full grid-cols-2 h-8">
<TabsList className="grid w-full grid-cols-3 h-8">
<TabsTrigger value="keys" className="text-xs">
{t("securityAuth.keys")}
</TabsTrigger>
<TabsTrigger value="passwords" className="text-xs">
{t("securityAuth.passwords")}
</TabsTrigger>
<TabsTrigger value="otp" className="text-xs">
{t("securityAuth.otp")}
</TabsTrigger>
</TabsList>
<TabsContent value="passwords" className="mt-3">
<PasswordManagementTab onCountChange={setPasswordCount} />
@@ -43,6 +51,9 @@ export default function SecurityAuthPanel() {
<TabsContent value="keys" className="mt-3">
<KeyManagementTab onCountChange={setKeyCount} />
</TabsContent>
<TabsContent value="otp" className="mt-3">
<OtpManagementTab onCountChange={setOtpCount} />
</TabsContent>
</Tabs>
</div>
</div>
@@ -0,0 +1,529 @@
import { invoke } from "@tauri-apps/api/core";
import { open as openFileDialog } from "@tauri-apps/plugin-dialog";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
MdAdd,
MdChevronRight,
MdDelete,
MdEdit,
MdQrCodeScanner,
MdRefresh,
MdVisibility,
MdVisibilityOff,
} from "react-icons/md";
import { toast } from "sonner";
import { OtpCodePanel } from "@/components/panel/OtpCodePanel";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NumberInput } from "@/components/ui/number-input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { OtpEntry } from "@/types/global";
interface OtpManagementTabProps {
onCountChange?: (count: number) => void;
}
function otpTypeTagClass(otpType: string) {
return otpType === "hotp" ? "text-amber-600 dark:text-amber-400" : "text-primary";
}
interface OtpEditorProps {
entry: Partial<OtpEntry>;
onCancel: () => void;
onChange: (patch: Partial<OtpEntry>) => void;
onSave: () => void;
saveDisabled: boolean;
secretLoading: boolean;
t: ReturnType<typeof useTranslation>["t"];
}
function OtpEditor({
entry,
onCancel,
onChange,
onSave,
saveDisabled,
secretLoading,
t,
}: OtpEditorProps) {
const [advancedOpen, setAdvancedOpen] = useState(false);
const otpType = entry.otp_type ?? "totp";
return (
<div className="space-y-2.5 border-b bg-accent/30 p-3">
<Tabs
value={otpType}
onValueChange={(value) => onChange({ otp_type: value })}
className="w-full"
>
<TabsList className="grid h-7 w-full grid-cols-2">
<TabsTrigger value="totp" className="text-xs">
TOTP
</TabsTrigger>
<TabsTrigger value="hotp" className="text-xs">
HOTP
</TabsTrigger>
</TabsList>
</Tabs>
<Input
placeholder={t("otpManager.issuerPlaceholder")}
className="h-8 text-xs"
value={entry.issuer ?? ""}
onChange={(event) => onChange({ issuer: event.target.value })}
autoFocus
/>
<Input
placeholder={t("otpManager.usernamePlaceholder")}
className="h-8 text-xs"
value={entry.username ?? ""}
onChange={(event) => onChange({ username: event.target.value })}
/>
<Input
type="password"
placeholder={secretLoading ? t("common.loading") : t("otpManager.secretPlaceholder")}
className="h-8 text-xs"
value={entry.secret ?? ""}
onChange={(event) => onChange({ secret: event.target.value })}
disabled={secretLoading}
/>
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger className="group flex w-full items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground">
<MdChevronRight
className={`text-sm transition-transform duration-200 ${advancedOpen ? "rotate-90" : ""}`}
/>
<span>{t("otpManager.advanced")}</span>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2 space-y-2.5">
<div>
<Label className="text-[0.6875rem] text-muted-foreground">
{t("otpManager.algorithm")}
</Label>
<Select
value={entry.algorithm ?? "SHA1"}
onValueChange={(value) => onChange({ algorithm: value })}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="SHA1">SHA-1</SelectItem>
<SelectItem value="SHA256">SHA-256</SelectItem>
<SelectItem value="SHA512">SHA-512</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[0.6875rem] text-muted-foreground">
{t("otpManager.digits")}
</Label>
<NumberInput
className="mt-1 [&_button]:h-8 [&_button]:w-8 [&_input]:h-8 [&_input]:text-xs"
value={entry.digits ?? 6}
onChange={(value) => onChange({ digits: value })}
min={4}
max={10}
/>
</div>
{otpType === "totp" ? (
<div>
<Label className="text-[0.6875rem] text-muted-foreground">
{t("otpManager.period")}
</Label>
<NumberInput
className="mt-1 [&_button]:h-8 [&_button]:w-8 [&_input]:h-8 [&_input]:text-xs"
value={entry.period ?? 30}
onChange={(value) => onChange({ period: value })}
min={10}
max={300}
/>
</div>
) : (
<div>
<Label className="text-[0.6875rem] text-muted-foreground">
{t("otpManager.counter")}
</Label>
<NumberInput
className="mt-1 [&_button]:h-8 [&_button]:w-8 [&_input]:h-8 [&_input]:text-xs"
value={entry.counter ?? 0}
onChange={(value) => onChange({ counter: value })}
min={0}
max={999999999}
/>
</div>
)}
</CollapsibleContent>
</Collapsible>
<div className="flex justify-end gap-1.5 pt-0.5">
<Button variant="outline" size="sm" className="h-7 px-3 text-xs" onClick={onCancel}>
{t("common.cancel")}
</Button>
<Button size="sm" className="h-7 px-3 text-xs" onClick={onSave} disabled={saveDisabled}>
{t("common.save")}
</Button>
</div>
</div>
);
}
export function OtpManagementTab({ onCountChange }: OtpManagementTabProps) {
const { t } = useTranslation();
const [entries, setEntries] = useState<OtpEntry[]>([]);
const [editingId, setEditingId] = useState<string | null>(null);
const [editEntry, setEditEntry] = useState<Partial<OtpEntry>>({});
const [secretLoading, setSecretLoading] = useState(false);
const [isNew, setIsNew] = useState(false);
const [qrImporting, setQrImporting] = useState(false);
const [deletingEntry, setDeletingEntry] = useState<OtpEntry | null>(null);
const [visibleOtpIds, setVisibleOtpIds] = useState<Set<string>>(() => new Set());
const editRequestRef = useRef(0);
const loadEntries = useCallback(async () => {
try {
const result = await invoke<OtpEntry[]>("get_otp_entries");
setEntries(result);
setVisibleOtpIds((prev) => {
const next = new Set<string>();
for (const entry of result) {
if (prev.has(entry.id)) next.add(entry.id);
}
return next;
});
onCountChange?.(result.length);
} catch {
/* ignore */
}
}, [onCountChange]);
useEffect(() => {
void loadEntries();
}, [loadEntries]);
const resetEdit = useCallback(() => {
editRequestRef.current += 1;
setEditingId(null);
setEditEntry({});
setSecretLoading(false);
setIsNew(false);
}, []);
const handleAdd = useCallback(() => {
resetEdit();
setEditingId("__new__");
setEditEntry({
algorithm: "SHA1",
counter: 0,
digits: 6,
otp_type: "totp",
period: 30,
});
setIsNew(true);
}, [resetEdit]);
const handleImportQr = useCallback(async () => {
if (qrImporting || editingId !== null) return;
setQrImporting(true);
try {
const selected = await openFileDialog({
multiple: false,
title: t("otpManager.selectQrImage"),
filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg", "bmp", "gif", "webp"] }],
});
const selectedPath = Array.isArray(selected) ? selected[0] : selected;
if (!selectedPath) return;
const parsed = await invoke<OtpEntry>("import_otp_from_qr", { path: selectedPath });
resetEdit();
setEditingId("__new__");
setEditEntry({
algorithm: parsed.algorithm,
counter: parsed.counter,
digits: parsed.digits,
issuer: parsed.issuer,
otp_type: parsed.otp_type,
period: parsed.period,
secret: parsed.secret ?? "",
username: parsed.username,
});
setIsNew(true);
} catch (error) {
toast.error(t("otpManager.qrImportFailed"), { description: String(error) });
} finally {
setQrImporting(false);
}
}, [editingId, qrImporting, resetEdit, t]);
const handleEdit = useCallback(async (entry: OtpEntry) => {
const requestId = ++editRequestRef.current;
setEditingId(entry.id);
setEditEntry({
...entry,
secret: "",
});
setSecretLoading(true);
setIsNew(false);
try {
const secret = await invoke<string | null>("get_otp_secret_value", { id: entry.id });
if (editRequestRef.current !== requestId) return;
setEditEntry((prev) => ({
...prev,
secret: secret ?? "",
}));
} catch {
if (editRequestRef.current !== requestId) return;
setEditEntry((prev) => ({
...prev,
secret: "",
}));
} finally {
if (editRequestRef.current === requestId) {
setSecretLoading(false);
}
}
}, []);
const handleSave = useCallback(async () => {
if (!editEntry.issuer?.trim()) return;
if (isNew && !editEntry.secret) return;
try {
await invoke("save_otp_entry", {
entry: {
algorithm: editEntry.algorithm ?? "SHA1",
counter: editEntry.counter ?? 0,
digits: editEntry.digits ?? 6,
id: isNew ? "" : editingId,
issuer: editEntry.issuer.trim(),
otp_type: editEntry.otp_type ?? "totp",
period: editEntry.period ?? 30,
secret: editEntry.secret || undefined,
username: editEntry.username?.trim() ?? "",
},
});
resetEdit();
await loadEntries();
} catch {
/* ignore */
}
}, [editEntry, editingId, isNew, loadEntries, resetEdit]);
const handleDeleteConfirm = useCallback(async () => {
if (!deletingEntry) return;
try {
await invoke("delete_otp_entry", { id: deletingEntry.id });
await loadEntries();
} catch {
/* ignore */
}
setDeletingEntry(null);
}, [deletingEntry, loadEntries]);
const handleChange = useCallback((patch: Partial<OtpEntry>) => {
setEditEntry((prev) => ({ ...prev, ...patch }));
}, []);
const toggleCodeVisibility = useCallback((id: string) => {
setVisibleOtpIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const actionsDisabled = editingId !== null || qrImporting;
return (
<div className="space-y-6">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">{t("otpManager.title")}</Label>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-primary"
onClick={() => void handleImportQr()}
disabled={actionsDisabled}
title={t("otpManager.scanQr")}
>
{qrImporting ? (
<MdRefresh className="mr-1 text-base animate-spin" />
) : (
<MdQrCodeScanner className="mr-1 text-base" />
)}
{qrImporting ? t("otpManager.scanningQr") : t("otpManager.scanQr")}
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-primary"
onClick={handleAdd}
disabled={actionsDisabled}
>
<MdAdd className="mr-1 text-base" /> {t("otpManager.add")}
</Button>
</div>
</div>
<div className="overflow-hidden rounded-md border">
{isNew && editingId === "__new__" ? (
<OtpEditor
entry={editEntry}
onCancel={resetEdit}
onChange={handleChange}
onSave={handleSave}
saveDisabled={secretLoading || !editEntry.issuer?.trim() || !editEntry.secret}
secretLoading={secretLoading}
t={t}
/>
) : null}
{entries.map((entry) => {
const codeVisible = visibleOtpIds.has(entry.id);
return (
<div key={entry.id}>
{editingId === entry.id && !isNew ? (
<OtpEditor
entry={editEntry}
onCancel={resetEdit}
onChange={handleChange}
onSave={handleSave}
saveDisabled={secretLoading || !editEntry.issuer?.trim()}
secretLoading={secretLoading}
t={t}
/>
) : (
<div className="border-b px-3 py-3 last:border-0 transition-colors hover:bg-accent/35">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<div className="truncate text-sm font-medium">{entry.issuer}</div>
<span
className={`shrink-0 font-mono text-[0.625rem] font-semibold uppercase tracking-[0.18em] ${otpTypeTagClass(entry.otp_type)}`}
>
[{entry.otp_type.toUpperCase()}]
</span>
</div>
<div className="mt-1 truncate text-[0.6875rem] text-muted-foreground">
{entry.username}
</div>
<div className="mt-2 flex flex-wrap items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-[0.6875rem] font-medium text-muted-foreground hover:text-foreground"
onClick={() => toggleCodeVisibility(entry.id)}
disabled={editingId !== null}
title={
codeVisible ? t("otpManager.hideCodes") : t("otpManager.showCodes")
}
>
{codeVisible ? (
<MdVisibilityOff className="mr-1 text-sm" />
) : (
<MdVisibility className="mr-1 text-sm" />
)}
{codeVisible ? t("otpManager.hide") : t("otpManager.view")}
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-[0.6875rem] font-medium text-muted-foreground hover:text-foreground"
onClick={() => {
void handleEdit(entry);
}}
disabled={editingId !== null}
>
<MdEdit className="mr-1 text-sm" />
{t("common.edit")}
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-[0.6875rem] font-medium text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => setDeletingEntry(entry)}
disabled={editingId !== null}
>
<MdDelete className="mr-1 text-sm" />
{t("common.delete")}
</Button>
</div>
</div>
</div>
{codeVisible ? (
<OtpCodePanel
className="mt-3"
otpEntryId={entry.id}
otpType={entry.otp_type}
period={entry.period}
variant="list"
/>
) : null}
</div>
)}
</div>
);
})}
{entries.length === 0 && !isNew ? (
<div className="py-6 text-center text-xs text-muted-foreground">
{t("otpManager.noEntries")}
</div>
) : null}
</div>
</div>
<Dialog
open={deletingEntry !== null}
onOpenChange={(open) => !open && setDeletingEntry(null)}
>
<DialogContent showCloseButton={false} className="max-w-sm">
<DialogHeader>
<DialogTitle>{t("otpManager.deleteTitle")}</DialogTitle>
<DialogDescription>
{t("otpManager.deleteConfirm", { name: deletingEntry?.issuer })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeletingEntry(null)}>
{t("common.cancel")}
</Button>
<Button variant="destructive" onClick={handleDeleteConfirm}>
{t("common.delete")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+185
View File
@@ -0,0 +1,185 @@
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useRef, useState } from "react";
import type { OtpCodeResult } from "@/types/global";
interface UseOtpCodeOptions {
autoStart?: boolean;
otpEntryId: string;
otpType?: string | null;
period?: number;
}
interface UseOtpCodeResult {
code: string;
copied: boolean;
copyCode: () => void;
fetchCode: () => Promise<void>;
hasCode: boolean;
isHotp: boolean;
isTotp: boolean;
loading: boolean;
progressPercent: number;
remaining: number;
}
const COPY_FEEDBACK_MS = 2000;
const AUTO_FETCH_RESULT_TTL_MS = 1000;
const pendingAutoRequests = new Map<string, Promise<OtpCodeResult>>();
const recentAutoResults = new Map<string, { expiresAt: number; result: OtpCodeResult }>();
async function invokeOtpCode(otpEntryId: string) {
return invoke<OtpCodeResult>("generate_otp_code", { id: otpEntryId });
}
async function requestOtpCodeWithAutoCache(otpEntryId: string, cacheKey: string) {
const now = Date.now();
const recent = recentAutoResults.get(cacheKey);
if (recent && recent.expiresAt > now) {
return recent.result;
}
const pending = pendingAutoRequests.get(cacheKey);
if (pending) {
return pending;
}
const request = invokeOtpCode(otpEntryId)
.then((result) => {
recentAutoResults.set(cacheKey, {
expiresAt: Date.now() + AUTO_FETCH_RESULT_TTL_MS,
result,
});
return result;
})
.finally(() => {
pendingAutoRequests.delete(cacheKey);
});
pendingAutoRequests.set(cacheKey, request);
return request;
}
export function useOtpCode({
autoStart = true,
otpEntryId,
otpType,
period = 30,
}: UseOtpCodeOptions): UseOtpCodeResult {
const [code, setCode] = useState("");
const [copied, setCopied] = useState(false);
const [loading, setLoading] = useState(false);
const [remaining, setRemaining] = useState(0);
const requestRef = useRef(0);
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const normalizedType = otpType?.toLowerCase();
const normalizedPeriod = period > 0 ? period : 30;
const isHotp = normalizedType === "hotp";
const isTotp = normalizedType === "totp";
const resetKey = `${otpEntryId}:${normalizedType ?? ""}:${normalizedPeriod}`;
const autoRequestKey = isTotp ? resetKey : "";
const clearCopyTimer = useCallback(() => {
if (copyTimerRef.current) {
clearTimeout(copyTimerRef.current);
copyTimerRef.current = null;
}
}, []);
const fetchCodeInternal = useCallback(
async (useAutoCache = false) => {
if (!otpEntryId || !normalizedType) return;
const requestId = ++requestRef.current;
setLoading(true);
try {
const result =
useAutoCache && autoRequestKey
? await requestOtpCodeWithAutoCache(otpEntryId, autoRequestKey)
: await invokeOtpCode(otpEntryId);
if (requestRef.current !== requestId) return;
setCode(result.code);
setRemaining(isTotp ? result.remainingSeconds : 0);
} catch {
if (requestRef.current !== requestId) return;
setCode("");
setRemaining(0);
} finally {
if (requestRef.current === requestId) {
setLoading(false);
}
}
},
[autoRequestKey, isTotp, normalizedType, otpEntryId],
);
const fetchCode = useCallback(async () => {
await fetchCodeInternal(false);
}, [fetchCodeInternal]);
const copyCode = useCallback(() => {
if (!code) return;
navigator.clipboard.writeText(code).catch(() => {});
setCopied(true);
clearCopyTimer();
copyTimerRef.current = setTimeout(() => {
setCopied(false);
copyTimerRef.current = null;
}, COPY_FEEDBACK_MS);
}, [clearCopyTimer, code]);
useEffect(() => {
void resetKey;
requestRef.current += 1;
setCode("");
setCopied(false);
setLoading(false);
setRemaining(0);
clearCopyTimer();
}, [clearCopyTimer, resetKey]);
useEffect(() => {
if (!isTotp || !autoStart || loading || code) return;
void fetchCodeInternal(true);
}, [autoStart, code, fetchCodeInternal, isTotp, loading]);
useEffect(() => {
if (!isTotp || !autoStart || remaining <= 0) return;
const timer = setTimeout(() => {
setRemaining((prev) => {
if (prev <= 1) {
void fetchCode();
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearTimeout(timer);
}, [autoStart, fetchCode, isTotp, remaining]);
useEffect(
() => () => {
requestRef.current += 1;
clearCopyTimer();
},
[clearCopyTimer],
);
return {
code,
copied,
copyCode,
fetchCode,
hasCode: Boolean(code),
isHotp,
isTotp,
loading,
progressPercent: isTotp ? Math.max(0, Math.min(100, (remaining / normalizedPeriod) * 100)) : 0,
remaining,
};
}
+39 -2
View File
@@ -209,6 +209,10 @@
"advancedConfig": "Advanced Configuration",
"proxySelect": "Proxy",
"noProxy": "No Proxy",
"twoFactorAuth": "Two-Factor Authentication",
"selectOtp": "OTP Entry",
"noOtp": "None",
"autoFillOtp": "Auto-fill verification code",
"connectionSaved": "Connection saved successfully!",
"cancel": "Cancel",
"save": "Save",
@@ -686,7 +690,8 @@
"securityAuth": {
"title": "Security Authentication",
"passwords": "Passwords",
"keys": "Keys"
"keys": "Keys",
"otp": "OTP"
},
"passwordManager": {
"title": "Password Manager",
@@ -711,7 +716,39 @@
"description": "\"{{name}}\" requires additional verification.",
"submit": "Verify",
"cancel": "Cancel",
"cancelled": "2FA authentication cancelled"
"cancelled": "2FA authentication cancelled",
"currentCode": "Current Code",
"sendToInput": "Send to Input",
"copyCode": "Copy Code",
"copied": "Copied",
"generateCode": "Generate Code",
"loadingCode": "Loading code",
"expiresIn": "{{seconds}}s remaining",
"hotpHint": "Generate on demand. The counter advances each time."
},
"otpManager": {
"title": "OTP Entries",
"add": "Add",
"noEntries": "No OTP entries",
"issuerPlaceholder": "Issuer (e.g. GitHub)",
"usernamePlaceholder": "Username (e.g. user@example.com)",
"secretPlaceholder": "Secret (Base32)",
"secretUnchanged": "Secret unchanged",
"advanced": "Advanced Settings",
"algorithm": "Algorithm",
"digits": "Digits",
"period": "Period (seconds)",
"counter": "Counter",
"deleteTitle": "Delete OTP Entry",
"deleteConfirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
"view": "View",
"hide": "Hide",
"scanQr": "Scan QR",
"showCodes": "Show codes",
"hideCodes": "Hide codes",
"scanningQr": "Scanning...",
"qrImportFailed": "Failed to import from QR code",
"selectQrImage": "Select QR Code Image"
}
}
+39 -2
View File
@@ -209,6 +209,10 @@
"advancedConfig": "高级配置",
"proxySelect": "代理",
"noProxy": "不使用代理",
"twoFactorAuth": "双因素认证",
"selectOtp": "OTP 条目",
"noOtp": "无",
"autoFillOtp": "自动填入验证码",
"connectionSaved": "连接保存成功!",
"cancel": "取消",
"save": "保存",
@@ -701,7 +705,8 @@
"securityAuth": {
"title": "安全认证",
"passwords": "密码",
"keys": "密钥"
"keys": "密钥",
"otp": "双因素"
},
"passwordManager": {
"title": "密码",
@@ -726,6 +731,38 @@
"description": "\"{{name}}\" 需要额外的身份验证。",
"submit": "验证",
"cancel": "取消",
"cancelled": "双因素认证已取消"
"cancelled": "双因素认证已取消",
"currentCode": "当前验证码",
"sendToInput": "发送到输入框",
"copyCode": "复制验证码",
"copied": "已复制",
"generateCode": "生成验证码",
"loadingCode": "正在加载验证码",
"expiresIn": "剩余 {{seconds}} 秒",
"hotpHint": "按需生成验证码,每次生成都会推进计数器。"
},
"otpManager": {
"title": "OTP 条目",
"add": "添加",
"noEntries": "暂无 OTP 条目",
"issuerPlaceholder": "发行方(如 GitHub)",
"usernamePlaceholder": "用户名(如 user@example.com)",
"secretPlaceholder": "密钥(Base32 编码)",
"secretUnchanged": "密钥未更改",
"advanced": "高级设置",
"algorithm": "算法",
"digits": "位数",
"period": "时间间隔(秒)",
"counter": "计数器",
"deleteTitle": "删除 OTP 条目",
"deleteConfirm": "确定要删除「{{name}}」吗?此操作无法撤销。",
"view": "查看",
"hide": "隐藏",
"scanQr": "扫描二维码",
"showCodes": "显示验证码",
"hideCodes": "隐藏验证码",
"scanningQr": "扫描中...",
"qrImportFailed": "从二维码导入失败",
"selectQrImage": "选择二维码图片"
}
}
+29
View File
@@ -85,9 +85,38 @@ export interface SavedConnection {
icon?: string;
/** References a standalone proxy config by id. */
proxy_id?: string;
/** References an OTP entry for two-factor authentication. */
otp_id?: string;
/** When true, auto-fill the OTP code during keyboard-interactive auth. */
auto_fill_otp?: boolean;
network?: ConnectionNetworkSettings;
}
/** Stored OTP entry for two-factor authentication. */
export interface OtpEntry {
id: string;
/** "totp" or "hotp". */
otp_type: string;
issuer: string;
username: string;
/** Base32-encoded secret (only sent when creating/updating). */
secret?: string;
algorithm: string;
digits: number;
/** Time step in seconds (TOTP only). */
period: number;
/** Counter value (HOTP only). */
counter: number;
/** True when encrypted secret data exists on disk. */
has_secret?: boolean;
}
/** Result of generating an OTP code. */
export interface OtpCodeResult {
code: string;
remainingSeconds: number;
}
/** Saved tab state for startup restoration. */
export interface RestorableTab {
title: string;