Merge feature/fullscreen-ux-phase-2a: smart-input affordances
Phase 2A of the fullscreen UX redesign — 8 form-level smart-input affordances (URL fill-from-tab + hostname chip, group autocomplete, password reveal + strength bar, TOTP live preview + QR decode, notes monospace toggle), shared between popup and fullscreen vault tabs via the new extension/src/shared/form-affordances/ module set. CLI parity: - relicario rate <passphrase> (zxcvbn score / guess estimate) - relicario completions <SHELL> (bash/zsh/fish via clap_complete) - --group <TAB> dynamic enumeration via .relicario/groups.cache (plaintext leak surface; opt out with RELICARIO_NO_GROUPS_CACHE=1) - --totp-qr <path> on add login + edit (rqrr decode) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
77
Cargo.lock
generated
77
Cargo.lock
generated
@@ -27,6 +27,12 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[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"
|
||||
@@ -357,6 +363,15 @@ dependencies = [
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_complete"
|
||||
version = "4.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "660c0520455b1013b9bcb0393d5f643d7e4454fb69c915b8d6d2aa0e9a45acc3"
|
||||
dependencies = [
|
||||
"clap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.0"
|
||||
@@ -749,6 +764,34 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[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",
|
||||
]
|
||||
|
||||
[[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 = "generic-array"
|
||||
version = "0.14.7"
|
||||
@@ -824,6 +867,8 @@ version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
@@ -1139,6 +1184,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 = "memchr"
|
||||
version = "2.8.0"
|
||||
@@ -1480,6 +1534,15 @@ version = "0.1.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
|
||||
|
||||
[[package]]
|
||||
name = "qrcode"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
|
||||
dependencies = [
|
||||
"image",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
@@ -1604,15 +1667,18 @@ dependencies = [
|
||||
"assert_cmd",
|
||||
"chrono",
|
||||
"clap",
|
||||
"clap_complete",
|
||||
"data-encoding",
|
||||
"dirs",
|
||||
"ed25519-dalek",
|
||||
"hex",
|
||||
"image",
|
||||
"predicates",
|
||||
"qrcode",
|
||||
"rand",
|
||||
"relicario-core",
|
||||
"rpassword",
|
||||
"rqrr",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tar",
|
||||
@@ -1680,6 +1746,17 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rqrr"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad0cd0432e6beb2f86aa4c8af1bb5edcf3c9bcb9d4836facc048664205458575"
|
||||
dependencies = [
|
||||
"g2p",
|
||||
"image",
|
||||
"lru",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rtoolbox"
|
||||
version = "0.0.5"
|
||||
|
||||
@@ -25,10 +25,13 @@ zeroize = "1"
|
||||
url = "2"
|
||||
data-encoding = "2"
|
||||
tar = { version = "0.4", default-features = false }
|
||||
clap_complete = "4"
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
|
||||
rqrr = "0.7"
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2"
|
||||
predicates = "3"
|
||||
tempfile = "3"
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||
qrcode = "0.14"
|
||||
serde_json = "1"
|
||||
|
||||
@@ -83,6 +83,66 @@ pub fn humanize_age(seconds: i64) -> String {
|
||||
|
||||
fn plural(n: i64) -> &'static str { if n == 1 { "" } else { "s" } }
|
||||
|
||||
/// Path to the plaintext `groups.cache` file used by shell completion to
|
||||
/// enumerate `--group <TAB>` candidates without unlocking the vault.
|
||||
///
|
||||
/// **Plaintext leak:** group names land on disk in cleartext alongside the
|
||||
/// vault directory. This is intentional — the file feeds shell completion,
|
||||
/// which cannot prompt for a passphrase. Set `RELICARIO_NO_GROUPS_CACHE=1`
|
||||
/// to suppress the write.
|
||||
pub fn groups_cache_path(vault_dir: &Path) -> PathBuf {
|
||||
vault_dir.join(".relicario").join("groups.cache")
|
||||
}
|
||||
|
||||
/// Write the sorted set of group names to `<vault_dir>/.relicario/groups.cache`,
|
||||
/// one name per line. A no-op if `RELICARIO_NO_GROUPS_CACHE` is set.
|
||||
pub fn write_groups_cache(
|
||||
vault_dir: &Path,
|
||||
groups: &std::collections::BTreeSet<String>,
|
||||
) -> std::io::Result<()> {
|
||||
if std::env::var_os("RELICARIO_NO_GROUPS_CACHE").is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let path = groups_cache_path(vault_dir);
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut body = String::new();
|
||||
for g in groups {
|
||||
body.push_str(g);
|
||||
body.push('\n');
|
||||
}
|
||||
std::fs::write(path, body)
|
||||
}
|
||||
|
||||
/// Decode a QR image at `path`. Returns the otpauth secret (base32) if the
|
||||
/// QR decodes to an `otpauth://...` URI with a `secret` query param.
|
||||
pub fn decode_totp_qr(path: &std::path::Path) -> anyhow::Result<String> {
|
||||
let img = image::open(path)
|
||||
.map_err(|e| anyhow::anyhow!("failed to read image: {e}"))?
|
||||
.to_luma8();
|
||||
let mut prepared = rqrr::PreparedImage::prepare(img);
|
||||
let grids = prepared.detect_grids();
|
||||
let grid = grids
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("no QR code found in image"))?;
|
||||
let (_meta, content) = grid
|
||||
.decode()
|
||||
.map_err(|e| anyhow::anyhow!("QR decode failed: {e}"))?;
|
||||
if !content.starts_with("otpauth://") {
|
||||
return Err(anyhow::anyhow!("not a TOTP URI (expected otpauth://...)"));
|
||||
}
|
||||
let parsed =
|
||||
url::Url::parse(&content).map_err(|e| anyhow::anyhow!("invalid otpauth URI: {e}"))?;
|
||||
let secret = parsed
|
||||
.query_pairs()
|
||||
.find(|(k, _)| k == "secret")
|
||||
.map(|(_, v)| v.to_string())
|
||||
.ok_or_else(|| anyhow::anyhow!("otpauth URI missing `secret` parameter"))?;
|
||||
Ok(secret)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -8,7 +8,8 @@ mod session;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use clap::{CommandFactory, Parser, Subcommand};
|
||||
use clap_complete::{generate, Shell};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
@@ -65,7 +66,12 @@ enum Commands {
|
||||
},
|
||||
|
||||
/// Edit an item interactively.
|
||||
Edit { query: String },
|
||||
Edit {
|
||||
query: String,
|
||||
/// Decode an `otpauth://` QR image to set the TOTP secret (login items only).
|
||||
#[arg(long, value_name = "PATH")]
|
||||
totp_qr: Option<PathBuf>,
|
||||
},
|
||||
|
||||
/// View captured field history for an item. Values are masked by
|
||||
/// default; pass `--show` to reveal them.
|
||||
@@ -163,6 +169,31 @@ enum Commands {
|
||||
|
||||
/// Lock the vault (no-op in CLI; present for UX parity with the extension).
|
||||
Lock,
|
||||
|
||||
/// Emit a shell completion script for the given shell.
|
||||
///
|
||||
/// For `--group <TAB>` autocomplete, the bash/zsh/fish scripts read
|
||||
/// the plaintext `${RELICARIO_VAULT}/.relicario/groups.cache` file,
|
||||
/// which the CLI refreshes on every manifest read. Set
|
||||
/// `RELICARIO_NO_GROUPS_CACHE=1` to opt out of the cache (completion
|
||||
/// will fall back to no value enumeration).
|
||||
///
|
||||
/// Pipe stdout to your shell's completion location (e.g.
|
||||
/// `relicario completions bash > /etc/bash_completion.d/relicario`).
|
||||
Completions {
|
||||
#[arg(value_enum)]
|
||||
shell: Shell,
|
||||
},
|
||||
|
||||
/// Rate a passphrase with zxcvbn — prints score (0-4) and estimated
|
||||
/// guesses. Informational only; does not gate vault operations.
|
||||
///
|
||||
/// Pass `-` as the argument to read one line from stdin instead, which
|
||||
/// keeps the passphrase out of shell history.
|
||||
Rate {
|
||||
/// Passphrase to score, or `-` to read from stdin.
|
||||
passphrase: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -177,6 +208,8 @@ enum AddKind {
|
||||
#[arg(long)] group: Option<String>,
|
||||
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||
#[arg(long)] favorite: bool,
|
||||
/// Decode an `otpauth://` QR image to fill the TOTP secret.
|
||||
#[arg(long, value_name = "PATH")] totp_qr: Option<PathBuf>,
|
||||
},
|
||||
SecureNote {
|
||||
#[arg(long)] title: Option<String>,
|
||||
@@ -334,7 +367,7 @@ fn main() -> Result<()> {
|
||||
Commands::Add { kind } => cmd_add(kind),
|
||||
Commands::Get { query, show, copy } => cmd_get(query, show, copy),
|
||||
Commands::List { r#type, group, tag, trashed } => cmd_list(r#type, group, tag, trashed),
|
||||
Commands::Edit { query } => cmd_edit(query),
|
||||
Commands::Edit { query, totp_qr } => cmd_edit(query, totp_qr),
|
||||
Commands::History { query, show, field } => cmd_history(query, show, field),
|
||||
Commands::Rm { query } => cmd_rm(query),
|
||||
Commands::Restore { query } => cmd_restore(query),
|
||||
@@ -354,9 +387,33 @@ fn main() -> Result<()> {
|
||||
Commands::Status => cmd_status(),
|
||||
Commands::Device { action } => cmd_device(action),
|
||||
Commands::Lock => { eprintln!("no cached session to lock"); Ok(()) }
|
||||
Commands::Completions { shell } => {
|
||||
let mut cmd = Cli::command();
|
||||
generate(shell, &mut cmd, "relicario", &mut std::io::stdout());
|
||||
Ok(())
|
||||
}
|
||||
Commands::Rate { passphrase } => cmd_rate(passphrase),
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect all non-empty group names from the manifest and write them to the
|
||||
/// plaintext `groups.cache` file so shell completion can enumerate `--group`
|
||||
/// candidates without prompting for the vault passphrase.
|
||||
///
|
||||
/// Failures are silently swallowed — a missing cache is merely a UX degradation,
|
||||
/// not a correctness problem.
|
||||
fn refresh_groups_cache(vault_dir: &std::path::Path, manifest: &relicario_core::Manifest) {
|
||||
let mut set = std::collections::BTreeSet::<String>::new();
|
||||
for entry in manifest.items.values() {
|
||||
if let Some(g) = entry.group.as_ref() {
|
||||
if !g.is_empty() {
|
||||
set.insert(g.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = helpers::write_groups_cache(vault_dir, &set);
|
||||
}
|
||||
|
||||
/// `rpassword::prompt_password` wrapper that honours `RELICARIO_TEST_ITEM_SECRET`
|
||||
/// for integration-test use (rpassword reads /dev/tty by default, which is
|
||||
/// unavailable in assert_cmd-spawned children).
|
||||
@@ -476,8 +533,8 @@ fn cmd_add(kind: AddKind) -> Result<()> {
|
||||
let mut manifest = vault.load_manifest()?;
|
||||
|
||||
let item = match kind {
|
||||
AddKind::Login { title, username, url, password_prompt, password, group, tags, favorite } =>
|
||||
build_login_item(title, username, url, password_prompt, password, group, tags, favorite)?,
|
||||
AddKind::Login { title, username, url, password_prompt, password, group, tags, favorite, totp_qr } =>
|
||||
build_login_item(title, username, url, password_prompt, password, group, tags, favorite, totp_qr)?,
|
||||
AddKind::SecureNote { title, body_prompt, group, tags } =>
|
||||
build_secure_note_item(title, body_prompt, group, tags)?,
|
||||
AddKind::Identity { title, full_name, email, phone, date_of_birth, group, tags } =>
|
||||
@@ -495,6 +552,7 @@ fn cmd_add(kind: AddKind) -> Result<()> {
|
||||
vault.save_item(&item)?;
|
||||
manifest.upsert(&item);
|
||||
vault.save_manifest(&manifest)?;
|
||||
refresh_groups_cache(vault.root(), &manifest);
|
||||
|
||||
let mut paths: Vec<String> = vec![
|
||||
format!("items/{}.enc", item.id.as_str()),
|
||||
@@ -525,8 +583,9 @@ fn build_login_item(
|
||||
group: Option<String>,
|
||||
tags: Vec<String>,
|
||||
favorite: bool,
|
||||
totp_qr: Option<PathBuf>,
|
||||
) -> Result<relicario_core::Item> {
|
||||
use relicario_core::item_types::LoginCore;
|
||||
use relicario_core::item_types::{LoginCore, TotpAlgorithm, TotpConfig, TotpKind};
|
||||
use relicario_core::{Item, ItemCore};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
@@ -544,8 +603,21 @@ fn build_login_item(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let totp = if let Some(path) = totp_qr {
|
||||
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
|
||||
let secret_bytes = base32_decode_lenient(&secret_b32)?;
|
||||
Some(TotpConfig {
|
||||
secret: Zeroizing::new(secret_bytes),
|
||||
algorithm: TotpAlgorithm::Sha1,
|
||||
digits: 6,
|
||||
period_seconds: 30,
|
||||
kind: TotpKind::Totp,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut item = Item::new(title, ItemCore::Login(LoginCore {
|
||||
username, password, url: parsed_url, totp: None,
|
||||
username, password, url: parsed_url, totp,
|
||||
}));
|
||||
item.group = group;
|
||||
item.tags = tags;
|
||||
@@ -835,6 +907,7 @@ fn cmd_get(query: String, show: bool, copy: bool) -> Result<()> {
|
||||
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let manifest = vault.load_manifest()?;
|
||||
refresh_groups_cache(vault.root(), &manifest);
|
||||
let entry = resolve_query(&manifest, &query)?;
|
||||
let item = vault.load_item(&entry.id)?;
|
||||
|
||||
@@ -852,6 +925,13 @@ fn cmd_get(query: String, show: bool, copy: bool) -> Result<()> {
|
||||
ItemCore::Login(l) => {
|
||||
if let Some(u) = &l.username { println!("Username: {u}"); }
|
||||
if let Some(u) = &l.url { println!("URL: {u}"); }
|
||||
if let Some(t) = &l.totp {
|
||||
if show {
|
||||
println!("TOTP: {}", data_encoding::BASE32.encode(&*t.secret));
|
||||
} else {
|
||||
println!("TOTP: **** (use --show to reveal)");
|
||||
}
|
||||
}
|
||||
if let Some(p) = &l.password { Some(p.clone()) } else { None }
|
||||
}
|
||||
ItemCore::SecureNote(n) => {
|
||||
@@ -952,6 +1032,7 @@ fn cmd_list(
|
||||
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let manifest = vault.load_manifest()?;
|
||||
refresh_groups_cache(vault.root(), &manifest);
|
||||
|
||||
let parsed_type: Option<ItemType> = match type_filter.as_deref() {
|
||||
None => None,
|
||||
@@ -990,7 +1071,7 @@ fn cmd_list(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn cmd_edit(query: String) -> Result<()> {
|
||||
fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> {
|
||||
use relicario_core::time::now_unix;
|
||||
use relicario_core::ItemCore;
|
||||
|
||||
@@ -1012,7 +1093,7 @@ fn cmd_edit(query: String) -> Result<()> {
|
||||
|
||||
let history = &mut item.field_history;
|
||||
match &mut item.core {
|
||||
ItemCore::Login(l) => edit_login(l, history)?,
|
||||
ItemCore::Login(l) => edit_login(l, history, totp_qr)?,
|
||||
ItemCore::SecureNote(n) => edit_secure_note(n, history)?,
|
||||
ItemCore::Identity(i) => edit_identity(i)?,
|
||||
ItemCore::Card(c) => edit_card(c, history)?,
|
||||
@@ -1025,6 +1106,7 @@ fn cmd_edit(query: String) -> Result<()> {
|
||||
vault.save_item(&item)?;
|
||||
manifest.upsert(&item);
|
||||
vault.save_manifest(&manifest)?;
|
||||
refresh_groups_cache(vault.root(), &manifest);
|
||||
commit_paths(&vault, &format!("edit: {} ({})", item.title, item.id.as_str()),
|
||||
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
||||
eprintln!("Updated {}", item.id.as_str());
|
||||
@@ -1040,7 +1122,12 @@ type FieldHistory = std::collections::HashMap<
|
||||
Vec<relicario_core::item::FieldHistoryEntry>,
|
||||
>;
|
||||
|
||||
fn edit_login(l: &mut relicario_core::item_types::LoginCore, history: &mut FieldHistory) -> Result<()> {
|
||||
fn edit_login(
|
||||
l: &mut relicario_core::item_types::LoginCore,
|
||||
history: &mut FieldHistory,
|
||||
totp_qr: Option<PathBuf>,
|
||||
) -> Result<()> {
|
||||
use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpKind};
|
||||
use zeroize::Zeroizing;
|
||||
if let Some(v) = prompt_keep_opt("Username", l.username.as_deref())? { l.username = Some(v); }
|
||||
if let Some(v) = prompt_keep_opt("URL", l.url.as_ref().map(|u| u.as_str()))? {
|
||||
@@ -1053,6 +1140,18 @@ fn edit_login(l: &mut relicario_core::item_types::LoginCore, history: &mut Field
|
||||
push_history(history, "login_password", Zeroizing::new(old_pw.as_str().to_string()));
|
||||
}
|
||||
}
|
||||
if let Some(path) = totp_qr {
|
||||
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
|
||||
let secret_bytes = base32_decode_lenient(&secret_b32)?;
|
||||
l.totp = Some(TotpConfig {
|
||||
secret: Zeroizing::new(secret_bytes),
|
||||
algorithm: TotpAlgorithm::Sha1,
|
||||
digits: 6,
|
||||
period_seconds: 30,
|
||||
kind: TotpKind::Totp,
|
||||
});
|
||||
eprintln!("TOTP secret set from QR image.");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1224,6 +1323,7 @@ fn cmd_rm(query: String) -> Result<()> {
|
||||
vault.save_item(&item)?;
|
||||
manifest.upsert(&item);
|
||||
vault.save_manifest(&manifest)?;
|
||||
refresh_groups_cache(vault.root(), &manifest);
|
||||
commit_paths(&vault, &format!("trash: {} ({})", item.title, item.id.as_str()),
|
||||
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
||||
eprintln!("Moved to trash: {}", item.title);
|
||||
@@ -1241,6 +1341,7 @@ fn cmd_restore(query: String) -> Result<()> {
|
||||
vault.save_item(&item)?;
|
||||
manifest.upsert(&item);
|
||||
vault.save_manifest(&manifest)?;
|
||||
refresh_groups_cache(vault.root(), &manifest);
|
||||
commit_paths(&vault, &format!("restore: {} ({})", item.title, item.id.as_str()),
|
||||
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
||||
eprintln!("Restored: {}", item.title);
|
||||
@@ -1282,6 +1383,7 @@ fn cmd_purge(query: String) -> Result<()> {
|
||||
|
||||
purge_item(&vault, &mut manifest, &id, &title)?;
|
||||
vault.save_manifest(&manifest)?;
|
||||
refresh_groups_cache(vault.root(), &manifest);
|
||||
|
||||
let status = crate::helpers::git_command(vault.root(), &["add", "manifest.enc"]).status()?;
|
||||
if !status.success() { anyhow::bail!("git add manifest.enc failed"); }
|
||||
@@ -2134,3 +2236,28 @@ struct ParamsKdf {
|
||||
argon2_t: u32,
|
||||
argon2_p: u32,
|
||||
}
|
||||
|
||||
fn cmd_rate(passphrase: String) -> Result<()> {
|
||||
let pw: String = if passphrase == "-" {
|
||||
use std::io::BufRead;
|
||||
let stdin = std::io::stdin();
|
||||
let mut line = String::new();
|
||||
stdin.lock().read_line(&mut line)?;
|
||||
line.trim_end_matches(&['\r', '\n'][..]).to_string()
|
||||
} else {
|
||||
passphrase
|
||||
};
|
||||
let est = relicario_core::generators::rate_passphrase(&pw);
|
||||
let label = match est.score {
|
||||
0 => "very weak",
|
||||
1 => "weak",
|
||||
2 => "fair",
|
||||
3 => "good",
|
||||
4 => "strong",
|
||||
_ => "?",
|
||||
};
|
||||
println!("score: {}/4 ({})", est.score, label);
|
||||
println!("guesses: ~10^{:.1}", est.guesses_log10);
|
||||
println!("note: init requires score ≥ 3 (see `relicario init`)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
210
crates/relicario-cli/tests/smart_inputs.rs
Normal file
210
crates/relicario-cli/tests/smart_inputs.rs
Normal file
@@ -0,0 +1,210 @@
|
||||
mod common;
|
||||
|
||||
use assert_cmd::Command;
|
||||
use predicates::prelude::PredicateBooleanExt;
|
||||
use predicates::str::contains;
|
||||
|
||||
#[test]
|
||||
fn completions_bash_emits_script() {
|
||||
Command::cargo_bin("relicario").unwrap()
|
||||
.args(["completions", "bash"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("_relicario"))
|
||||
.stdout(contains("complete -F"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completions_zsh_emits_script() {
|
||||
Command::cargo_bin("relicario").unwrap()
|
||||
.args(["completions", "zsh"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("#compdef relicario"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completions_fish_emits_script() {
|
||||
Command::cargo_bin("relicario").unwrap()
|
||||
.args(["completions", "fish"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("complete -c relicario"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_command_refreshes_groups_cache() {
|
||||
let v = common::TestVault::init();
|
||||
|
||||
let out = v.run(&[
|
||||
"add", "login",
|
||||
"--title", "T",
|
||||
"--username", "u",
|
||||
"--group", "work",
|
||||
"--password", "hunter2",
|
||||
]);
|
||||
assert!(out.status.success(), "add failed: {:?}", out);
|
||||
|
||||
let out = v.run(&["list"]);
|
||||
assert!(out.status.success(), "list failed: {:?}", out);
|
||||
|
||||
let cache_path = v.path().join(".relicario/groups.cache");
|
||||
let cache = std::fs::read_to_string(&cache_path)
|
||||
.unwrap_or_else(|e| panic!("groups.cache not found at {}: {e}", cache_path.display()));
|
||||
assert!(
|
||||
cache.lines().any(|l| l == "work"),
|
||||
"expected 'work' in groups.cache, got: {cache:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_groups_cache_env_var_suppresses_write() {
|
||||
use std::process::{Command as StdCommand, Stdio};
|
||||
use assert_cmd::cargo::CommandCargoExt as _;
|
||||
|
||||
let v = common::TestVault::init();
|
||||
|
||||
// Add with the env var set so no cache is created by add either.
|
||||
let out = StdCommand::cargo_bin("relicario").unwrap()
|
||||
.current_dir(v.path())
|
||||
.env("RELICARIO_IMAGE", &v.reference_image)
|
||||
.env("RELICARIO_TEST_PASSPHRASE", &v.passphrase)
|
||||
.env("RELICARIO_NO_GROUPS_CACHE", "1")
|
||||
.args([
|
||||
"add", "login",
|
||||
"--title", "T2",
|
||||
"--username", "u",
|
||||
"--group", "personal",
|
||||
"--password", "hunter2",
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "add failed: {:?}", out);
|
||||
|
||||
// Run list with RELICARIO_NO_GROUPS_CACHE=1 — cache must NOT be written.
|
||||
let out = StdCommand::cargo_bin("relicario").unwrap()
|
||||
.current_dir(v.path())
|
||||
.env("RELICARIO_IMAGE", &v.reference_image)
|
||||
.env("RELICARIO_TEST_PASSPHRASE", &v.passphrase)
|
||||
.env("RELICARIO_NO_GROUPS_CACHE", "1")
|
||||
.args(["list"])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "list failed: {:?}", out);
|
||||
|
||||
let cache_path = v.path().join(".relicario/groups.cache");
|
||||
assert!(
|
||||
!cache_path.exists(),
|
||||
"groups.cache should not exist when RELICARIO_NO_GROUPS_CACHE=1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_strong_passphrase_prints_score_and_guesses() {
|
||||
Command::cargo_bin("relicario").unwrap()
|
||||
.args(["rate", "correct horse battery staple table cocoa rocket spirit ferment"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("score:"))
|
||||
.stdout(contains("guesses:"))
|
||||
.stdout(contains("strong"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_weak_passphrase_exits_zero_with_weak_label() {
|
||||
// `rate` is informational — does NOT exit nonzero on weak input.
|
||||
// The hard gate lives at `init` (Plan 2B Task 10).
|
||||
Command::cargo_bin("relicario").unwrap()
|
||||
.args(["rate", "password"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("very weak").or(contains("weak")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_reads_from_stdin_when_arg_is_dash() {
|
||||
Command::cargo_bin("relicario").unwrap()
|
||||
.args(["rate", "-"])
|
||||
.write_stdin("correcthorsebatterystaple\n")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("score:"));
|
||||
}
|
||||
|
||||
fn make_test_qr(uri: &str, dest: &std::path::Path) {
|
||||
use image::{ImageBuffer, Luma};
|
||||
let code = qrcode::QrCode::new(uri).expect("QR encode failed");
|
||||
let img: ImageBuffer<Luma<u8>, Vec<u8>> = code
|
||||
.render::<Luma<u8>>()
|
||||
.module_dimensions(8, 8)
|
||||
.build();
|
||||
img.save(dest).expect("save QR PNG");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_login_totp_qr_decodes_otpauth_uri() {
|
||||
use tempfile::TempDir;
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let qr_path = tmp.path().join("test.png");
|
||||
make_test_qr(
|
||||
"otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example",
|
||||
&qr_path,
|
||||
);
|
||||
|
||||
let v = common::TestVault::init();
|
||||
|
||||
let out = v.run(&[
|
||||
"add", "login",
|
||||
"--title", "TotpTest",
|
||||
"--password", "hunter2",
|
||||
"--totp-qr", qr_path.to_str().unwrap(),
|
||||
]);
|
||||
assert!(out.status.success(), "add failed:\nstdout: {}\nstderr: {}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
let out = v.run(&["get", "TotpTest", "--show"]);
|
||||
assert!(out.status.success(), "get failed:\nstdout: {}\nstderr: {}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr));
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
// BASE32.encode(BASE32.decode("JBSWY3DPEHPK3PXP")) should round-trip.
|
||||
// The secret bytes from JBSWY3DPEHPK3PXP decode to specific bytes,
|
||||
// then re-encode to JBSWY3DPEHPK3PXP====; we check for the core chars.
|
||||
assert!(
|
||||
stdout.contains("JBSWY3DPEHPK3PXP"),
|
||||
"expected TOTP secret in get output, got:\n{stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_login_totp_qr_errors_on_non_otpauth_qr() {
|
||||
use tempfile::TempDir;
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let qr_path = tmp.path().join("nottotp.png");
|
||||
make_test_qr("https://example.com", &qr_path);
|
||||
|
||||
let v = common::TestVault::init();
|
||||
|
||||
let out = v.run(&[
|
||||
"add", "login",
|
||||
"--title", "BadQR",
|
||||
"--password", "hunter2",
|
||||
"--totp-qr", qr_path.to_str().unwrap(),
|
||||
]);
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"expected nonzero exit for non-otpauth QR, but command succeeded"
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains("not a TOTP URI"),
|
||||
"expected 'not a TOTP URI' in stderr, got:\n{stderr}"
|
||||
);
|
||||
}
|
||||
3528
extension/package-lock.json
generated
Normal file
3528
extension/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,9 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"jsqr": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chrome": "^0.1.40",
|
||||
"copy-webpack-plugin": "^12.0",
|
||||
|
||||
122
extension/src/popup/components/types/__tests__/login.test.ts
Normal file
122
extension/src/popup/components/types/__tests__/login.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../../shared/state', async () => {
|
||||
const navigate = vi.fn();
|
||||
const setState = vi.fn();
|
||||
const sendMessage = vi.fn();
|
||||
const getState = vi.fn(() => ({
|
||||
view: 'add', entries: [], selectedId: null, selectedItem: null, selectedIndex: 0,
|
||||
searchQuery: '', activeGroup: null, error: null, loading: false,
|
||||
capturedTabId: null, capturedUrl: '', newType: 'login',
|
||||
generatorDefaults: null,
|
||||
}));
|
||||
const escapeHtml = (s: string) => s
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
return {
|
||||
navigate, setState, sendMessage, getState, escapeHtml,
|
||||
popOutToTab: vi.fn(), isInTab: vi.fn(() => false), openVaultTab: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock setup-helpers (scheduleRate used by wirePasswordStrength)
|
||||
vi.mock('../../../../setup/setup-helpers', () => ({
|
||||
scheduleRate: vi.fn(),
|
||||
STRENGTH_LABELS: {},
|
||||
entropyText: vi.fn(() => ''),
|
||||
}));
|
||||
|
||||
import { renderForm } from '../login';
|
||||
import { sendMessage } from '../../../../shared/state';
|
||||
|
||||
describe('login form smart inputs', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '<div id="app"></div>';
|
||||
// chrome.storage.local stub (needed by wireNotesMonoToggle)
|
||||
(globalThis as any).chrome = {
|
||||
storage: {
|
||||
local: {
|
||||
get: vi.fn().mockImplementation((_keys: any, cb: any) => cb({})),
|
||||
set: vi.fn().mockImplementation((_obj: any, cb: any) => cb && cb()),
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
sendMessage: vi.fn(),
|
||||
},
|
||||
};
|
||||
vi.mocked(sendMessage).mockReset();
|
||||
vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { groups: [] } });
|
||||
});
|
||||
|
||||
it('renders all 6 smart-input slots in the form', async () => {
|
||||
const app = document.getElementById('app')!;
|
||||
renderForm(app, 'add', null);
|
||||
|
||||
expect(document.querySelector('#fill-from-tab-btn')).not.toBeNull();
|
||||
expect(document.querySelector('#hostname-chip-row')).not.toBeNull();
|
||||
expect(document.querySelector('#reveal-password-btn')).not.toBeNull();
|
||||
expect(document.querySelector('#strength-bar-row')).not.toBeNull();
|
||||
expect(document.querySelector('#totp-preview-row')).not.toBeNull();
|
||||
expect(document.querySelector('#totp-qr-btn')).not.toBeNull();
|
||||
expect(document.querySelector('#totp-qr-panel')).not.toBeNull();
|
||||
expect(document.querySelector('#notes-mono-btn')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Login save shape', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '<div id="app"></div>';
|
||||
(globalThis as any).chrome = {
|
||||
storage: {
|
||||
local: {
|
||||
get: vi.fn().mockImplementation((_keys: any, cb: any) => cb({})),
|
||||
set: vi.fn().mockImplementation((_obj: any, cb: any) => cb && cb()),
|
||||
},
|
||||
},
|
||||
runtime: { sendMessage: vi.fn() },
|
||||
};
|
||||
vi.mocked(sendMessage).mockReset();
|
||||
vi.mocked(sendMessage).mockImplementation(async (msg: any) => {
|
||||
if (msg.type === 'list_groups') return { ok: true, data: { groups: [] } };
|
||||
if (msg.type === 'preview_totp_from_secret') return { ok: false };
|
||||
return { ok: true, data: { id: 'fakeid0000000000', items: [] } };
|
||||
});
|
||||
});
|
||||
|
||||
it('saves a login item with url, username, and password', async () => {
|
||||
const app = document.getElementById('app')!;
|
||||
renderForm(app, 'add', null);
|
||||
|
||||
(document.getElementById('f-title') as HTMLInputElement).value = 'GitHub';
|
||||
(document.getElementById('f-url') as HTMLInputElement).value = 'https://github.com/login';
|
||||
(document.getElementById('f-username') as HTMLInputElement).value = 'alice';
|
||||
(document.getElementById('f-password') as HTMLInputElement).value = 'hunter2';
|
||||
|
||||
document.getElementById('save-btn')!.click();
|
||||
await new Promise(r => setTimeout(r, 5));
|
||||
|
||||
const calls = vi.mocked(sendMessage).mock.calls;
|
||||
const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item');
|
||||
expect(addCall).toBeDefined();
|
||||
const msg = addCall![0] as { type: 'add_item'; item: any };
|
||||
expect(msg.item.type).toBe('login');
|
||||
expect(msg.item.core).toMatchObject({
|
||||
type: 'login',
|
||||
username: 'alice',
|
||||
password: 'hunter2',
|
||||
url: 'https://github.com/login',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects save when title is empty', async () => {
|
||||
const app = document.getElementById('app')!;
|
||||
renderForm(app, 'add', null);
|
||||
|
||||
document.getElementById('save-btn')!.click();
|
||||
await new Promise(r => setTimeout(r, 5));
|
||||
|
||||
const calls = vi.mocked(sendMessage).mock.calls;
|
||||
const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item');
|
||||
expect(addCall).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -22,10 +22,20 @@ import {
|
||||
wireAttachmentsDisclosure,
|
||||
teardownAttachmentsDisclosure,
|
||||
} from '../attachments-disclosure';
|
||||
import { wireFillFromTab, wireHostnameChip } from '../../../shared/form-affordances/url-tools';
|
||||
import { wireGroupAutocomplete } from '../../../shared/form-affordances/group-autocomplete';
|
||||
import { wirePasswordReveal, wirePasswordStrength } from '../../../shared/form-affordances/password-tools';
|
||||
import { wireTotpPreview, wireTotpQr } from '../../../shared/form-affordances/totp-tools';
|
||||
import { wireNotesMonoToggle } from '../../../shared/form-affordances/notes-tools';
|
||||
import { scheduleRate } from '../../../setup/setup-helpers';
|
||||
|
||||
/// Called by the dispatcher before each render. Stops any in-flight
|
||||
/// tickers / intervals / listeners the previous view may have attached.
|
||||
export function teardown(): void {
|
||||
for (const fn of pendingAffordanceTeardowns) {
|
||||
try { fn(); } catch { /* best effort */ }
|
||||
}
|
||||
pendingAffordanceTeardowns = [];
|
||||
teardownAttachmentsDisclosure();
|
||||
stopTotpTicker();
|
||||
if (activeKeyHandler) {
|
||||
@@ -202,6 +212,7 @@ let totpTickerId: ReturnType<typeof setInterval> | null = null;
|
||||
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||
let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||
let sectionsExpanded = false;
|
||||
let pendingAffordanceTeardowns: Array<() => void> = [];
|
||||
function stopTotpTicker(): void {
|
||||
if (totpTickerId !== null) { clearInterval(totpTickerId); totpTickerId = null; }
|
||||
}
|
||||
@@ -247,23 +258,63 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
||||
<div class="pad">
|
||||
${renderFormHeader({ titleText: mode === 'add' ? 'new login' : 'edit login' })}
|
||||
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||
|
||||
<div class="form-group"><label class="label" for="f-title">title ${REQUIRED_PILL_HTML}</label>
|
||||
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="GitHub"></div>
|
||||
<div class="form-group"><label class="label" for="f-url">url</label>
|
||||
<input id="f-url" type="text" value="${escapeHtml(url)}" placeholder="https://github.com/login"></div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-url">url</label>
|
||||
<div class="inline-row">
|
||||
<input id="f-url" type="text" value="${escapeHtml(url)}" placeholder="https://github.com/login">
|
||||
<button id="fill-from-tab-btn" class="glyph-btn" type="button" title="fill from active tab">⤓</button>
|
||||
</div>
|
||||
<div id="hostname-chip-row" class="hostname-chip-row" hidden></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group"><label class="label" for="f-username">username</label>
|
||||
<input id="f-username" type="text" value="${escapeHtml(username)}" placeholder="alice@example.com"></div>
|
||||
<div class="form-group"><label class="label" for="f-password">password</label>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-password">password</label>
|
||||
<div class="inline-row">
|
||||
<input id="f-password" type="password" value="${escapeHtml(password)}">
|
||||
<button class="gen-trigger" id="gen-btn" type="button" title="generate password" aria-expanded="false">✨</button>
|
||||
</div></div>
|
||||
<div class="form-group"><label class="label" for="f-totp">totp secret (base32)</label>
|
||||
<input id="f-totp" type="text" value="${escapeHtml(totpStr)}" placeholder="JBSWY3DPEHPK3PXP"></div>
|
||||
<button id="reveal-password-btn" class="glyph-btn" type="button" title="reveal">⊙</button>
|
||||
<button class="gen-trigger" id="gen-btn" type="button" title="generate password" aria-expanded="false">↻</button>
|
||||
</div>
|
||||
<div id="strength-bar-row" class="strength-bar-row" hidden>
|
||||
<div class="strength-bar"><span></span><span></span><span></span><span></span><span></span></div>
|
||||
<div class="strength-label"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-totp">totp secret (base32)</label>
|
||||
<div class="inline-row">
|
||||
<input id="f-totp" type="text" value="${escapeHtml(totpStr)}" placeholder="JBSWY3DPEHPK3PXP">
|
||||
<button id="totp-qr-btn" class="glyph-btn" type="button" title="paste / upload QR">◫</button>
|
||||
</div>
|
||||
<div id="totp-preview-row" class="totp-preview" hidden>
|
||||
<span class="totp-code">…</span>
|
||||
<span class="totp-countdown">…</span>
|
||||
</div>
|
||||
<div id="totp-qr-panel" class="totp-qr-panel" hidden>
|
||||
<input id="totp-qr-file" type="file" accept="image/*" />
|
||||
<div style="font-size:10px;color:var(--text-dim,#6b7888);margin-top:4px;">paste image, drop image, or pick a file</div>
|
||||
<div id="totp-qr-error" class="totp-qr-error"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group"><label class="label" for="f-group">group</label>
|
||||
<input id="f-group" type="text" value="${escapeHtml(group)}" placeholder="work"></div>
|
||||
<div class="form-group"><label class="label" for="f-notes">notes</label>
|
||||
<textarea id="f-notes" placeholder="recovery codes, security questions...">${escapeHtml(notes)}</textarea></div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="notes-with-toggle">
|
||||
<label class="label" for="f-notes" style="margin:0;flex:1;">notes</label>
|
||||
<button id="notes-mono-btn" class="glyph-btn" type="button" title="toggle monospace">≡</button>
|
||||
</div>
|
||||
<textarea id="f-notes" placeholder="recovery codes, security questions...">${escapeHtml(notes)}</textarea>
|
||||
</div>
|
||||
|
||||
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
|
||||
${isInTab() ? renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' }) : ''}
|
||||
<div class="form-actions">
|
||||
@@ -305,6 +356,26 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
||||
wireDisclosure();
|
||||
}
|
||||
|
||||
// ---- Smart input affordances ------------------------------------------
|
||||
// Each wireXxx call attaches event listeners to the just-rendered form.
|
||||
// Affordances that hold timers/intervals return a teardown fn we collect
|
||||
// here and run from the form's existing teardown() entry point.
|
||||
const affordanceTeardowns: Array<() => void> = [];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sm = sendMessage as any;
|
||||
wireFillFromTab(app, { sendMessage: sm });
|
||||
wireHostnameChip(app);
|
||||
void wireGroupAutocomplete(app, { sendMessage: sm });
|
||||
affordanceTeardowns.push(wirePasswordReveal(app));
|
||||
wirePasswordStrength(app, { scheduleRate });
|
||||
affordanceTeardowns.push(wireTotpPreview(app, { sendMessage: sm }));
|
||||
wireTotpQr(app);
|
||||
void wireNotesMonoToggle(app, { itemId: existing?.id ?? '' });
|
||||
|
||||
// Stash teardown-runner so the existing `teardown()` calls it.
|
||||
pendingAffordanceTeardowns = affordanceTeardowns;
|
||||
|
||||
document.getElementById('gen-btn')?.addEventListener('click', (e) => {
|
||||
const trigger = e.currentTarget as HTMLElement;
|
||||
if (isGeneratorPanelOpen()) {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
--bg-page: #0d1117;
|
||||
--bg-pane: #161b22;
|
||||
--bg-elevated: #21262d;
|
||||
--bg-input: #161b22;
|
||||
--border-subtle: #30363d;
|
||||
|
||||
/* Text */
|
||||
@@ -1332,3 +1333,127 @@ textarea {
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Glyph button used by smart-input affordances. Sits inline with an input. */
|
||||
.glyph-btn {
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
padding: 0 6px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 3px;
|
||||
color: var(--text-muted);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.glyph-btn:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.glyph-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
.glyph-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.hostname-chip-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.hostname-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #0c1118;
|
||||
}
|
||||
.hostname-text {
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
.strength-bar-row {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.strength-bar {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
height: 4px;
|
||||
}
|
||||
.strength-bar > span {
|
||||
flex: 1;
|
||||
background: var(--border-subtle);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.strength-bar.s-very-weak > span.lit { background: #c75a4f; }
|
||||
.strength-bar.s-weak > span.lit { background: #c75a4f; }
|
||||
.strength-bar.s-fair > span.lit { background: #d49b3a; }
|
||||
.strength-bar.s-good > span.lit { background: #d49b3a; }
|
||||
.strength-bar.s-strong > span.lit { background: #6cb37a; }
|
||||
.strength-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.totp-preview {
|
||||
margin-top: 6px;
|
||||
padding: 6px 10px;
|
||||
border: 1px dashed var(--border-subtle);
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.totp-code {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.totp-countdown {
|
||||
font-size: 11px;
|
||||
}
|
||||
.totp-qr-panel {
|
||||
margin-top: 6px;
|
||||
padding: 10px;
|
||||
border: 1px dashed var(--border-subtle);
|
||||
border-radius: 3px;
|
||||
background: var(--bg-input);
|
||||
}
|
||||
.totp-qr-panel input[type="file"] {
|
||||
display: block;
|
||||
font-family: inherit;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.totp-qr-error {
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--danger, #c75a4f);
|
||||
}
|
||||
.notes-with-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.f-notes--mono {
|
||||
font-family: ui-monospace, "JetBrains Mono", "SF Mono", monospace !important;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// --- Mocks (must be declared before `route` is imported so the router's
|
||||
// `import * as vault` / `import * as session` resolve to these doubles) ---
|
||||
@@ -921,3 +921,120 @@ describe('parse_lastpass_csv / import_lastpass_commit sender check', () => {
|
||||
expect(result).toEqual({ ok: false, error: 'unauthorized_sender' });
|
||||
});
|
||||
});
|
||||
|
||||
// --- get_active_tab_url ---
|
||||
|
||||
describe('get_active_tab_url', () => {
|
||||
let originalChrome: any;
|
||||
beforeEach(() => { originalChrome = (globalThis as any).chrome; });
|
||||
afterEach(() => { (globalThis as any).chrome = originalChrome; });
|
||||
|
||||
it('get_active_tab_url returns active tab url + title', async () => {
|
||||
// happy-dom does not provide chrome.tabs; stub it.
|
||||
(globalThis as any).chrome = {
|
||||
...((globalThis as any).chrome ?? {}),
|
||||
tabs: {
|
||||
query: (q: any, cb: (tabs: any[]) => void) => {
|
||||
cb([{ url: 'https://github.com/login', title: 'Sign in to GitHub' }]);
|
||||
},
|
||||
},
|
||||
};
|
||||
const resp = await route({ type: 'get_active_tab_url' } as any, makeState(), makePopupSender());
|
||||
expect(resp.ok).toBe(true);
|
||||
expect(resp.data).toEqual({ url: 'https://github.com/login', title: 'Sign in to GitHub' });
|
||||
});
|
||||
|
||||
it('get_active_tab_url returns null for chrome:// pages', async () => {
|
||||
(globalThis as any).chrome = {
|
||||
...((globalThis as any).chrome ?? {}),
|
||||
tabs: {
|
||||
query: (q: any, cb: (tabs: any[]) => void) => {
|
||||
cb([{ url: 'chrome://newtab/', title: 'New Tab' }]);
|
||||
},
|
||||
},
|
||||
};
|
||||
const resp = await route({ type: 'get_active_tab_url' } as any, makeState(), makePopupSender());
|
||||
expect(resp.ok).toBe(true);
|
||||
expect(resp.data).toBeNull();
|
||||
});
|
||||
|
||||
it('get_active_tab_url returns null for view-source: URLs', async () => {
|
||||
(globalThis as any).chrome = {
|
||||
...((globalThis as any).chrome ?? {}),
|
||||
tabs: {
|
||||
query: (q: any, cb: (tabs: any[]) => void) => {
|
||||
cb([{ url: 'view-source:https://github.com/login', title: 'View Source' }]);
|
||||
},
|
||||
},
|
||||
};
|
||||
const resp = await route({ type: 'get_active_tab_url' } as any, makeState(), makePopupSender());
|
||||
expect(resp.ok).toBe(true);
|
||||
expect(resp.data).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// --- list_groups ---
|
||||
|
||||
describe('list_groups', () => {
|
||||
it('list_groups returns deduplicated sorted groups from manifest', async () => {
|
||||
const state = makeState();
|
||||
state.manifest = {
|
||||
schema_version: 2,
|
||||
items: {
|
||||
a: { id: 'a', title: 't1', type: 'login', group: 'work', tags: [], modified: 0, created: 0, favorite: false, attachment_summaries: [] },
|
||||
b: { id: 'b', title: 't2', type: 'login', group: 'personal', tags: [], modified: 0, created: 0, favorite: false, attachment_summaries: [] },
|
||||
c: { id: 'c', title: 't3', type: 'login', group: 'work', tags: [], modified: 0, created: 0, favorite: false, attachment_summaries: [] },
|
||||
d: { id: 'd', title: 't4', type: 'login', tags: [], modified: 0, created: 0, favorite: false, attachment_summaries: [] }, // no group
|
||||
},
|
||||
};
|
||||
const resp = await route({ type: 'list_groups' } as any, state, makePopupSender());
|
||||
expect(resp.ok).toBe(true);
|
||||
expect(resp.data).toEqual({ groups: ['personal', 'work'] });
|
||||
});
|
||||
|
||||
it('list_groups returns empty array when manifest is null', async () => {
|
||||
const state = makeState();
|
||||
state.manifest = null;
|
||||
const resp = await route({ type: 'list_groups' } as any, state, makePopupSender());
|
||||
expect(resp.ok).toBe(true);
|
||||
expect(resp.data).toEqual({ groups: [] });
|
||||
});
|
||||
});
|
||||
|
||||
// --- preview_totp_from_secret ---
|
||||
|
||||
describe('preview_totp_from_secret', () => {
|
||||
let originalChrome: any;
|
||||
beforeEach(() => { originalChrome = (globalThis as any).chrome; });
|
||||
afterEach(() => { (globalThis as any).chrome = originalChrome; });
|
||||
|
||||
it('returns code for valid base32', async () => {
|
||||
const state = makeState();
|
||||
state.wasm = {
|
||||
totp_compute: vi.fn().mockReturnValue({ code: '123456', expires_at: 9_999_999_999 }),
|
||||
};
|
||||
const resp = await route(
|
||||
{ type: 'preview_totp_from_secret', secret_b32: 'JBSWY3DPEHPK3PXP' } as any,
|
||||
state, makePopupSender(),
|
||||
);
|
||||
expect(resp.ok).toBe(true);
|
||||
expect(resp.data).toEqual({ code: '123456', expires_at: 9_999_999_999 });
|
||||
// Verify a transient TotpConfig was passed (sha1, 6 digits, 30s)
|
||||
const cfgArg = JSON.parse(state.wasm.totp_compute.mock.calls[0][0]);
|
||||
expect(cfgArg.algorithm).toBe('sha1');
|
||||
expect(cfgArg.digits).toBe(6);
|
||||
expect(cfgArg.period_seconds).toBe(30);
|
||||
});
|
||||
|
||||
it('rejects invalid base32', async () => {
|
||||
const state = makeState();
|
||||
state.wasm = { totp_compute: vi.fn() };
|
||||
const resp = await route(
|
||||
{ type: 'preview_totp_from_secret', secret_b32: 'too-short!!!' } as any,
|
||||
state, makePopupSender(),
|
||||
);
|
||||
expect(resp.ok).toBe(false);
|
||||
expect(resp.error).toMatch(/invalid/i);
|
||||
expect(state.wasm.totp_compute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import type { PopupMessage, Response } from '../../shared/messages';
|
||||
import type { Item, ItemId, Manifest, VaultConfig, SetupState, DeviceSettings, TotpConfig, AttachmentRef } from '../../shared/types';
|
||||
import { DEFAULT_DEVICE_SETTINGS } from '../../shared/types';
|
||||
import { base32Decode } from '../../shared/base32';
|
||||
import type { GitHost } from '../git-host';
|
||||
import { createGitHost, base64ToUint8Array } from '../git-host';
|
||||
import * as vault from '../vault';
|
||||
@@ -146,6 +147,52 @@ export async function handle(
|
||||
case 'rate_passphrase':
|
||||
return { ok: true, data: state.wasm.rate_passphrase(msg.passphrase) };
|
||||
|
||||
case 'get_active_tab_url': {
|
||||
const tabs = await new Promise<chrome.tabs.Tab[]>((resolve) => {
|
||||
chrome.tabs.query({ active: true, lastFocusedWindow: true }, (t) => resolve(t));
|
||||
});
|
||||
const tab = tabs[0];
|
||||
if (!tab?.url) return { ok: true, data: null };
|
||||
// Filter out chrome:// and extension URLs — autofill doesn't apply.
|
||||
if (/^(chrome|chrome-extension|chrome-search|moz-extension|edge|edge-extension|about|file|view-source|data|devtools|javascript):/i.test(tab.url)) {
|
||||
return { ok: true, data: null };
|
||||
}
|
||||
return { ok: true, data: { url: tab.url, title: tab.title ?? '' } };
|
||||
}
|
||||
|
||||
case 'list_groups': {
|
||||
if (!state.manifest) return { ok: true, data: { groups: [] } };
|
||||
const set = new Set<string>();
|
||||
for (const id in state.manifest.items) {
|
||||
const g = state.manifest.items[id].group;
|
||||
if (g) set.add(g);
|
||||
}
|
||||
return { ok: true, data: { groups: Array.from(set).sort() } };
|
||||
}
|
||||
|
||||
case 'preview_totp_from_secret': {
|
||||
const cleaned = msg.secret_b32.toUpperCase().replace(/\s+/g, '').replace(/=+$/, '');
|
||||
if (cleaned.length < 16 || !/^[A-Z2-7]+$/.test(cleaned)) {
|
||||
return { ok: false, error: 'invalid base32 secret' };
|
||||
}
|
||||
let secretBytes: Uint8Array;
|
||||
try {
|
||||
secretBytes = base32Decode(cleaned);
|
||||
} catch (e) {
|
||||
return { ok: false, error: `invalid base32: ${e instanceof Error ? e.message : String(e)}` };
|
||||
}
|
||||
const cfg = {
|
||||
secret: Array.from(secretBytes),
|
||||
algorithm: 'sha1',
|
||||
digits: 6,
|
||||
period_seconds: 30,
|
||||
kind: 'totp',
|
||||
};
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const result = state.wasm.totp_compute(JSON.stringify(cfg), BigInt(now));
|
||||
return { ok: true, data: { code: result.code, expires_at: result.expires_at } };
|
||||
}
|
||||
|
||||
case 'generate_password': {
|
||||
const password = state.wasm.generate_password(JSON.stringify(msg.request));
|
||||
return { ok: true, data: { password } };
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { wireGroupAutocomplete } from '../group-autocomplete';
|
||||
|
||||
describe('wireGroupAutocomplete', () => {
|
||||
let form: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
// Clean up any datalist from a prior test
|
||||
document.getElementById('groups-datalist')?.remove();
|
||||
form = document.createElement('div');
|
||||
form.innerHTML = `<input id="f-group" type="text" />`;
|
||||
document.body.appendChild(form);
|
||||
});
|
||||
|
||||
it('attaches datalist with all groups', async () => {
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
data: { groups: ['personal', 'work', 'finance'] },
|
||||
});
|
||||
await wireGroupAutocomplete(form, { sendMessage });
|
||||
const list = document.getElementById('groups-datalist') as HTMLDataListElement | null;
|
||||
expect(list).not.toBeNull();
|
||||
const opts = Array.from(list!.querySelectorAll('option')).map((o) => o.value);
|
||||
expect(opts).toEqual(['personal', 'work', 'finance']);
|
||||
const input = form.querySelector('#f-group') as HTMLInputElement;
|
||||
expect(input.getAttribute('list')).toBe('groups-datalist');
|
||||
});
|
||||
|
||||
it('is a no-op if SW returns error', async () => {
|
||||
const sendMessage = vi.fn().mockResolvedValue({ ok: false, error: 'vault_locked' });
|
||||
await wireGroupAutocomplete(form, { sendMessage });
|
||||
const input = form.querySelector('#f-group') as HTMLInputElement;
|
||||
expect(input.getAttribute('list')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as affordances from '../index';
|
||||
|
||||
describe('form-affordances barrel', () => {
|
||||
it('exports nothing yet but the module loads', () => {
|
||||
expect(typeof affordances).toBe('object');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { wireNotesMonoToggle } from '../notes-tools';
|
||||
|
||||
describe('wireNotesMonoToggle', () => {
|
||||
let form: HTMLElement;
|
||||
let storage: { get: ReturnType<typeof vi.fn>; set: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
form = document.createElement('div');
|
||||
form.innerHTML = `
|
||||
<button id="notes-mono-btn" class="glyph-btn" type="button" title="monospace">≡</button>
|
||||
<textarea id="f-notes"></textarea>
|
||||
`;
|
||||
document.body.appendChild(form);
|
||||
storage = {
|
||||
get: vi.fn().mockImplementation((_keys, cb) => cb({})),
|
||||
set: vi.fn().mockImplementation((_obj, cb) => cb && cb()),
|
||||
};
|
||||
(globalThis as any).chrome = { storage: { local: storage } };
|
||||
});
|
||||
|
||||
it('toggles class on click and persists', async () => {
|
||||
await wireNotesMonoToggle(form, { itemId: 'abc123' });
|
||||
const btn = form.querySelector('#notes-mono-btn') as HTMLButtonElement;
|
||||
const ta = form.querySelector('#f-notes') as HTMLTextAreaElement;
|
||||
expect(ta.classList.contains('f-notes--mono')).toBe(false);
|
||||
btn.click();
|
||||
expect(ta.classList.contains('f-notes--mono')).toBe(true);
|
||||
expect(storage.set).toHaveBeenCalledWith({ 'notesMono.abc123': true }, expect.any(Function));
|
||||
});
|
||||
|
||||
it('restores prior state on mount', async () => {
|
||||
storage.get.mockImplementation((_keys, cb) => cb({ 'notesMono.abc123': true }));
|
||||
await wireNotesMonoToggle(form, { itemId: 'abc123' });
|
||||
const ta = form.querySelector('#f-notes') as HTMLTextAreaElement;
|
||||
expect(ta.classList.contains('f-notes--mono')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { wirePasswordReveal, wirePasswordStrength } from '../password-tools';
|
||||
|
||||
describe('wirePasswordReveal', () => {
|
||||
let form: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
form = document.createElement('div');
|
||||
form.innerHTML = `
|
||||
<input id="f-password" type="password" value="secret" />
|
||||
<button id="reveal-password-btn" class="glyph-btn" type="button" title="reveal">⊙</button>
|
||||
`;
|
||||
document.body.appendChild(form);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.removeChild(form);
|
||||
});
|
||||
|
||||
it('flips input.type and glyph on click', () => {
|
||||
wirePasswordReveal(form);
|
||||
const btn = form.querySelector('#reveal-password-btn') as HTMLButtonElement;
|
||||
const input = form.querySelector('#f-password') as HTMLInputElement;
|
||||
expect(input.type).toBe('password');
|
||||
expect(btn.textContent).toBe('⊙');
|
||||
|
||||
btn.click();
|
||||
expect(input.type).toBe('text');
|
||||
expect(btn.textContent).toBe('⊘');
|
||||
expect(btn.title).toBe('hide');
|
||||
|
||||
btn.click();
|
||||
expect(input.type).toBe('password');
|
||||
expect(btn.textContent).toBe('⊙');
|
||||
expect(btn.title).toBe('reveal');
|
||||
});
|
||||
|
||||
it('teardown returned by wirePasswordReveal resets to password', () => {
|
||||
const teardown = wirePasswordReveal(form);
|
||||
const btn = form.querySelector('#reveal-password-btn') as HTMLButtonElement;
|
||||
const input = form.querySelector('#f-password') as HTMLInputElement;
|
||||
btn.click(); // now revealed
|
||||
expect(input.type).toBe('text');
|
||||
teardown();
|
||||
expect(input.type).toBe('password');
|
||||
expect(btn.textContent).toBe('⊙');
|
||||
expect(btn.title).toBe('reveal');
|
||||
});
|
||||
});
|
||||
|
||||
describe('wirePasswordStrength', () => {
|
||||
let form: HTMLElement;
|
||||
let scheduleRate: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
form = document.createElement('div');
|
||||
form.innerHTML = `
|
||||
<input id="f-password" type="password" value="" />
|
||||
<div id="strength-bar-row" class="strength-bar-row" hidden>
|
||||
<div class="strength-bar"><span></span><span></span><span></span><span></span><span></span></div>
|
||||
<div class="strength-label"></div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(form);
|
||||
scheduleRate = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.removeChild(form);
|
||||
});
|
||||
|
||||
it('shows bar with score class on input', () => {
|
||||
scheduleRate.mockImplementation((_pw, cb) => cb({ score: 3, guessesLog10: 11.4 }));
|
||||
wirePasswordStrength(form, { scheduleRate });
|
||||
const input = form.querySelector('#f-password') as HTMLInputElement;
|
||||
input.value = 'CorrectHorseBatteryStaple';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
const row = form.querySelector('#strength-bar-row') as HTMLElement;
|
||||
expect(row.hidden).toBe(false);
|
||||
expect(row.querySelector('.strength-bar')?.className).toContain('s-good');
|
||||
expect(row.querySelector('.strength-label')?.textContent).toContain('good');
|
||||
expect(row.querySelector('.strength-label')?.textContent).toContain('10^11');
|
||||
});
|
||||
|
||||
it('hides bar when input is empty', () => {
|
||||
scheduleRate.mockImplementation((_pw, cb) => cb({ score: -1, guessesLog10: -1 }));
|
||||
wirePasswordStrength(form, { scheduleRate });
|
||||
const input = form.querySelector('#f-password') as HTMLInputElement;
|
||||
input.value = '';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
const row = form.querySelector('#strength-bar-row') as HTMLElement;
|
||||
expect(row.hidden).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { wireTotpPreview, wireTotpQr } from '../totp-tools';
|
||||
|
||||
describe('wireTotpPreview', () => {
|
||||
let form: HTMLElement;
|
||||
let sendMessage: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
form = document.createElement('div');
|
||||
form.innerHTML = `
|
||||
<input id="f-totp" type="text" value="" />
|
||||
<div id="totp-preview-row" class="totp-preview" hidden>
|
||||
<span class="totp-code">…</span>
|
||||
<span class="totp-countdown">…</span>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(form);
|
||||
sendMessage = vi.fn();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
it('shows preview when secret is valid base32', async () => {
|
||||
sendMessage.mockResolvedValue({ ok: true, data: { code: '492837', expires_at: Math.floor(Date.now() / 1000) + 23 } });
|
||||
const teardown = wireTotpPreview(form, { sendMessage });
|
||||
const input = form.querySelector('#f-totp') as HTMLInputElement;
|
||||
input.value = 'JBSWY3DPEHPK3PXP';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
const row = form.querySelector('#totp-preview-row') as HTMLElement;
|
||||
expect(row.hidden).toBe(false);
|
||||
expect(row.querySelector('.totp-code')?.textContent).toBe('492 837');
|
||||
expect(row.querySelector('.totp-countdown')?.textContent).toMatch(/\d+s/);
|
||||
teardown();
|
||||
});
|
||||
|
||||
it('hides preview when secret is too short', async () => {
|
||||
const teardown = wireTotpPreview(form, { sendMessage });
|
||||
const input = form.querySelector('#f-totp') as HTMLInputElement;
|
||||
input.value = 'TOOSHORT';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
const row = form.querySelector('#totp-preview-row') as HTMLElement;
|
||||
expect(row.hidden).toBe(true);
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
teardown();
|
||||
});
|
||||
|
||||
it('teardown stops the interval', async () => {
|
||||
sendMessage.mockResolvedValue({ ok: true, data: { code: '111111', expires_at: Math.floor(Date.now() / 1000) + 30 } });
|
||||
const teardown = wireTotpPreview(form, { sendMessage });
|
||||
const input = form.querySelector('#f-totp') as HTMLInputElement;
|
||||
input.value = 'JBSWY3DPEHPK3PXP';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
const callsBefore = sendMessage.mock.calls.length;
|
||||
teardown();
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(sendMessage.mock.calls.length).toBe(callsBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wireTotpQr', () => {
|
||||
let form: HTMLElement;
|
||||
let decodeQrFromBlob: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
form = document.createElement('div');
|
||||
form.innerHTML = `
|
||||
<input id="f-totp" type="text" value="" />
|
||||
<button id="totp-qr-btn" class="glyph-btn" type="button" title="QR">◫</button>
|
||||
<div id="totp-qr-panel" class="totp-qr-panel" hidden>
|
||||
<input id="totp-qr-file" type="file" accept="image/*" />
|
||||
<div id="totp-qr-error" class="totp-qr-error"></div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(form);
|
||||
decodeQrFromBlob = vi.fn();
|
||||
});
|
||||
|
||||
it('toggles the panel on button click', () => {
|
||||
wireTotpQr(form, { decodeQrFromBlob });
|
||||
const btn = form.querySelector('#totp-qr-btn') as HTMLButtonElement;
|
||||
const panel = form.querySelector('#totp-qr-panel') as HTMLElement;
|
||||
expect(panel.hidden).toBe(true);
|
||||
btn.click();
|
||||
expect(panel.hidden).toBe(false);
|
||||
btn.click();
|
||||
expect(panel.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('fills f-totp on successful decode of otpauth:// URI', async () => {
|
||||
decodeQrFromBlob.mockResolvedValue('otpauth://totp/Example:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example');
|
||||
wireTotpQr(form, { decodeQrFromBlob });
|
||||
const fileInput = form.querySelector('#totp-qr-file') as HTMLInputElement;
|
||||
const fakeFile = new File(['x'], 'qr.png', { type: 'image/png' });
|
||||
Object.defineProperty(fileInput, 'files', { value: [fakeFile] });
|
||||
fileInput.dispatchEvent(new Event('change'));
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
expect((form.querySelector('#f-totp') as HTMLInputElement).value).toBe('JBSWY3DPEHPK3PXP');
|
||||
});
|
||||
|
||||
it('shows error when QR decodes but is not otpauth://', async () => {
|
||||
decodeQrFromBlob.mockResolvedValue('https://example.com/');
|
||||
wireTotpQr(form, { decodeQrFromBlob });
|
||||
const fileInput = form.querySelector('#totp-qr-file') as HTMLInputElement;
|
||||
Object.defineProperty(fileInput, 'files', { value: [new File(['x'], 'x.png', { type: 'image/png' })] });
|
||||
fileInput.dispatchEvent(new Event('change'));
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
const err = form.querySelector('#totp-qr-error') as HTMLElement;
|
||||
expect(err.textContent).toMatch(/not a totp uri/i);
|
||||
expect((form.querySelector('#f-totp') as HTMLInputElement).value).toBe('');
|
||||
});
|
||||
|
||||
it('shows error when decode returns null (no QR found)', async () => {
|
||||
decodeQrFromBlob.mockResolvedValue(null);
|
||||
wireTotpQr(form, { decodeQrFromBlob });
|
||||
const fileInput = form.querySelector('#totp-qr-file') as HTMLInputElement;
|
||||
Object.defineProperty(fileInput, 'files', { value: [new File(['x'], 'x.png', { type: 'image/png' })] });
|
||||
fileInput.dispatchEvent(new Event('change'));
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
const err = form.querySelector('#totp-qr-error') as HTMLElement;
|
||||
expect(err.textContent).toMatch(/no qr found/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { wireFillFromTab, wireHostnameChip } from '../url-tools';
|
||||
|
||||
describe('wireFillFromTab', () => {
|
||||
let form: HTMLElement;
|
||||
let sendMessage: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
form = document.createElement('div');
|
||||
form.innerHTML = `
|
||||
<input id="f-title" type="text" />
|
||||
<div class="inline-row">
|
||||
<input id="f-url" type="text" />
|
||||
<button id="fill-from-tab-btn" class="glyph-btn" type="button" title="fill from active tab">⤓</button>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(form);
|
||||
sendMessage = vi.fn();
|
||||
});
|
||||
|
||||
it('fills url + title from active tab on click', async () => {
|
||||
sendMessage.mockResolvedValue({ ok: true, data: { url: 'https://github.com/login', title: 'GitHub' } });
|
||||
wireFillFromTab(form, { sendMessage });
|
||||
(form.querySelector('#fill-from-tab-btn') as HTMLButtonElement).click();
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
expect((form.querySelector('#f-url') as HTMLInputElement).value).toBe('https://github.com/login');
|
||||
expect((form.querySelector('#f-title') as HTMLInputElement).value).toBe('GitHub');
|
||||
});
|
||||
|
||||
it('does not overwrite a non-empty title', async () => {
|
||||
(form.querySelector('#f-title') as HTMLInputElement).value = 'My GitHub';
|
||||
sendMessage.mockResolvedValue({ ok: true, data: { url: 'https://github.com/login', title: 'GitHub' } });
|
||||
wireFillFromTab(form, { sendMessage });
|
||||
(form.querySelector('#fill-from-tab-btn') as HTMLButtonElement).click();
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
expect((form.querySelector('#f-title') as HTMLInputElement).value).toBe('My GitHub');
|
||||
});
|
||||
|
||||
it('disables the button if SW returns null', async () => {
|
||||
sendMessage.mockResolvedValue({ ok: true, data: null });
|
||||
wireFillFromTab(form, { sendMessage });
|
||||
(form.querySelector('#fill-from-tab-btn') as HTMLButtonElement).click();
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
expect((form.querySelector('#fill-from-tab-btn') as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wireHostnameChip', () => {
|
||||
let form: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
form = document.createElement('div');
|
||||
form.innerHTML = `
|
||||
<div class="form-group">
|
||||
<input id="f-url" type="text" />
|
||||
<div id="hostname-chip-row" class="hostname-chip-row" hidden></div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(form);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
it('renders chip + hostname on valid URL after debounce', () => {
|
||||
wireHostnameChip(form);
|
||||
const input = form.querySelector('#f-url') as HTMLInputElement;
|
||||
input.value = 'https://github.com/login';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
vi.advanceTimersByTime(250);
|
||||
const row = form.querySelector('#hostname-chip-row') as HTMLElement;
|
||||
expect(row.hidden).toBe(false);
|
||||
expect(row.textContent).toContain('github.com');
|
||||
expect(row.querySelector('.hostname-chip')?.textContent).toBe('G');
|
||||
});
|
||||
|
||||
it('hides chip if URL is empty', () => {
|
||||
wireHostnameChip(form);
|
||||
const input = form.querySelector('#f-url') as HTMLInputElement;
|
||||
input.value = '';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
vi.advanceTimersByTime(250);
|
||||
expect((form.querySelector('#hostname-chip-row') as HTMLElement).hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('hides chip if URL does not parse', () => {
|
||||
wireHostnameChip(form);
|
||||
const input = form.querySelector('#f-url') as HTMLInputElement;
|
||||
input.value = '!!!not-a-url';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
vi.advanceTimersByTime(250);
|
||||
expect((form.querySelector('#hostname-chip-row') as HTMLElement).hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('treats scheme-less host as https://', () => {
|
||||
wireHostnameChip(form);
|
||||
const input = form.querySelector('#f-url') as HTMLInputElement;
|
||||
input.value = 'gitlab.com/users/sign_in';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
vi.advanceTimersByTime(250);
|
||||
const row = form.querySelector('#hostname-chip-row') as HTMLElement;
|
||||
expect(row.hidden).toBe(false);
|
||||
expect(row.textContent).toContain('gitlab.com');
|
||||
});
|
||||
});
|
||||
28
extension/src/shared/form-affordances/group-autocomplete.ts
Normal file
28
extension/src/shared/form-affordances/group-autocomplete.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export interface GroupAutocompleteOpts {
|
||||
sendMessage: (msg: { type: 'list_groups' }) => Promise<{ ok: boolean; data?: { groups: string[] }; error?: string }>;
|
||||
}
|
||||
|
||||
const DATALIST_ID = 'groups-datalist';
|
||||
|
||||
export async function wireGroupAutocomplete(form: HTMLElement, opts: GroupAutocompleteOpts): Promise<void> {
|
||||
const input = form.querySelector<HTMLInputElement>('#f-group');
|
||||
if (!input) return;
|
||||
let resp: Awaited<ReturnType<typeof opts.sendMessage>>;
|
||||
try {
|
||||
resp = await opts.sendMessage({ type: 'list_groups' });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!resp?.ok || !resp.data?.groups) return;
|
||||
|
||||
// Datalists must live in the document, not nested inside an input. Reuse if
|
||||
// we've already mounted one this session.
|
||||
let list = document.getElementById(DATALIST_ID) as HTMLDataListElement | null;
|
||||
if (!list) {
|
||||
list = document.createElement('datalist');
|
||||
list.id = DATALIST_ID;
|
||||
document.body.appendChild(list);
|
||||
}
|
||||
list.innerHTML = resp.data.groups.map((g) => `<option value="${g.replace(/"/g, '"')}"></option>`).join('');
|
||||
input.setAttribute('list', DATALIST_ID);
|
||||
}
|
||||
5
extension/src/shared/form-affordances/index.ts
Normal file
5
extension/src/shared/form-affordances/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/// Shared form affordance modules. Each named export wires one family of
|
||||
/// smart-input behavior (url, group, password, totp, notes) into a mounted
|
||||
/// form element. Wired by `popup/components/types/login.ts` after the form
|
||||
/// HTML is rendered.
|
||||
export {};
|
||||
29
extension/src/shared/form-affordances/notes-tools.ts
Normal file
29
extension/src/shared/form-affordances/notes-tools.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export interface NotesMonoOpts {
|
||||
/// Item ID for persistence — pass empty string for "add new" forms (state
|
||||
/// is then session-scoped under the key 'notesMono.__new__').
|
||||
itemId: string;
|
||||
}
|
||||
|
||||
export async function wireNotesMonoToggle(form: HTMLElement, opts: NotesMonoOpts): Promise<void> {
|
||||
const btn = form.querySelector<HTMLButtonElement>('#notes-mono-btn');
|
||||
const ta = form.querySelector<HTMLTextAreaElement>('#f-notes');
|
||||
if (!btn || !ta) return;
|
||||
|
||||
const key = `notesMono.${opts.itemId || '__new__'}`;
|
||||
|
||||
// chrome.storage may be absent in test environments — guard gracefully.
|
||||
if (typeof chrome !== 'undefined' && chrome.storage?.local) {
|
||||
const stored = await new Promise<boolean>((resolve) => {
|
||||
chrome.storage.local.get([key], (result) => resolve(!!result[key]));
|
||||
});
|
||||
if (stored) ta.classList.add('f-notes--mono');
|
||||
}
|
||||
|
||||
btn.addEventListener('click', () => {
|
||||
const next = !ta.classList.contains('f-notes--mono');
|
||||
ta.classList.toggle('f-notes--mono', next);
|
||||
if (typeof chrome !== 'undefined' && chrome.storage?.local) {
|
||||
chrome.storage.local.set({ [key]: next }, () => { /* fire and forget */ });
|
||||
}
|
||||
});
|
||||
}
|
||||
67
extension/src/shared/form-affordances/password-tools.ts
Normal file
67
extension/src/shared/form-affordances/password-tools.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { GLYPH_REVEAL, GLYPH_HIDE } from '../glyphs';
|
||||
import { STRENGTH_LABELS, entropyText, type Strength } from '../../setup/setup-helpers';
|
||||
|
||||
/// Returns a teardown fn the caller must invoke on unmount.
|
||||
export function wirePasswordReveal(form: HTMLElement): () => void {
|
||||
const btn = form.querySelector<HTMLButtonElement>('#reveal-password-btn');
|
||||
const input = form.querySelector<HTMLInputElement>('#f-password');
|
||||
if (!btn || !input) return () => {};
|
||||
|
||||
const handler = () => {
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
btn.textContent = GLYPH_HIDE;
|
||||
btn.title = 'hide';
|
||||
} else {
|
||||
input.type = 'password';
|
||||
btn.textContent = GLYPH_REVEAL;
|
||||
btn.title = 'reveal';
|
||||
}
|
||||
};
|
||||
btn.addEventListener('click', handler);
|
||||
|
||||
return () => {
|
||||
btn.removeEventListener('click', handler);
|
||||
input.type = 'password';
|
||||
btn.textContent = GLYPH_REVEAL;
|
||||
btn.title = 'reveal';
|
||||
};
|
||||
}
|
||||
|
||||
export interface PasswordStrengthOpts {
|
||||
scheduleRate: (passphrase: string, cb: (s: Strength) => void) => void;
|
||||
}
|
||||
|
||||
export function wirePasswordStrength(form: HTMLElement, opts: PasswordStrengthOpts): void {
|
||||
const input = form.querySelector<HTMLInputElement>('#f-password');
|
||||
const row = form.querySelector<HTMLElement>('#strength-bar-row');
|
||||
if (!input || !row) return;
|
||||
const bar = row.querySelector<HTMLElement>('.strength-bar');
|
||||
const label = row.querySelector<HTMLElement>('.strength-label');
|
||||
if (!bar || !label) return;
|
||||
|
||||
const update = () => {
|
||||
const v = input.value;
|
||||
if (!v) {
|
||||
row.hidden = true;
|
||||
return;
|
||||
}
|
||||
opts.scheduleRate(v, (s) => {
|
||||
if (s.score < 0) { row.hidden = true; return; }
|
||||
row.hidden = false;
|
||||
// Reset score classes, then add the current one to the bar element.
|
||||
bar.className = 'strength-bar';
|
||||
const cls = STRENGTH_LABELS[s.score]?.cls ?? 's-very-weak';
|
||||
bar.classList.add(cls);
|
||||
// Light up segments 0..score (5-segment bar).
|
||||
Array.from(bar.children).forEach((seg, i) => {
|
||||
(seg as HTMLElement).classList.toggle('lit', i <= s.score);
|
||||
});
|
||||
const text = STRENGTH_LABELS[s.score]?.text ?? '?';
|
||||
label.textContent = `${text} · ${entropyText(s.guessesLog10)}`;
|
||||
});
|
||||
};
|
||||
|
||||
input.addEventListener('input', update);
|
||||
update();
|
||||
}
|
||||
142
extension/src/shared/form-affordances/totp-tools.ts
Normal file
142
extension/src/shared/form-affordances/totp-tools.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
export interface TotpPreviewOpts {
|
||||
sendMessage: (msg: { type: 'preview_totp_from_secret'; secret_b32: string }) =>
|
||||
Promise<{ ok: boolean; data?: { code: string; expires_at: number }; error?: string }>;
|
||||
}
|
||||
|
||||
const VALID_B32 = /^[A-Z2-7]{16,}=*$/;
|
||||
|
||||
export function wireTotpPreview(form: HTMLElement, opts: TotpPreviewOpts): () => void {
|
||||
const input = form.querySelector<HTMLInputElement>('#f-totp');
|
||||
const row = form.querySelector<HTMLElement>('#totp-preview-row');
|
||||
if (!input || !row) return () => {};
|
||||
const codeEl = row.querySelector<HTMLElement>('.totp-code');
|
||||
const cdEl = row.querySelector<HTMLElement>('.totp-countdown');
|
||||
if (!codeEl || !cdEl) return () => {};
|
||||
|
||||
let interval: ReturnType<typeof setInterval> | null = null;
|
||||
let lastSecret = '';
|
||||
|
||||
const tick = async () => {
|
||||
const cleaned = lastSecret.toUpperCase().replace(/\s+/g, '').replace(/=+$/, '');
|
||||
if (!VALID_B32.test(cleaned)) {
|
||||
row.hidden = true;
|
||||
return;
|
||||
}
|
||||
const resp = await opts.sendMessage({ type: 'preview_totp_from_secret', secret_b32: cleaned });
|
||||
if (!resp.ok || !resp.data) {
|
||||
row.hidden = true;
|
||||
return;
|
||||
}
|
||||
row.hidden = false;
|
||||
// Format "492837" → "492 837" for legibility.
|
||||
codeEl.textContent = resp.data.code.length === 6
|
||||
? `${resp.data.code.slice(0, 3)} ${resp.data.code.slice(3)}`
|
||||
: resp.data.code;
|
||||
const remaining = Math.max(0, resp.data.expires_at - Math.floor(Date.now() / 1000));
|
||||
cdEl.textContent = `${remaining}s`;
|
||||
};
|
||||
|
||||
const onInput = () => {
|
||||
lastSecret = input.value;
|
||||
void tick();
|
||||
};
|
||||
input.addEventListener('input', onInput);
|
||||
if (interval === null) {
|
||||
interval = setInterval(() => { void tick(); }, 1000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
input.removeEventListener('input', onInput);
|
||||
if (interval !== null) { clearInterval(interval); interval = null; }
|
||||
row.hidden = true;
|
||||
};
|
||||
}
|
||||
|
||||
/// Lazy-load jsqr and decode a QR from a Blob/File. Returns the decoded
|
||||
/// string, or null if no QR was found.
|
||||
async function defaultDecodeQrFromBlob(blob: Blob): Promise<string | null> {
|
||||
const [{ default: jsQR }] = await Promise.all([import('jsqr')]);
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
ctx.drawImage(bitmap, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const result = jsQR(imageData.data, imageData.width, imageData.height);
|
||||
return result?.data ?? null;
|
||||
}
|
||||
|
||||
export interface TotpQrOpts {
|
||||
/// Inject a stub in tests where canvas + imports aren't available.
|
||||
decodeQrFromBlob?: (blob: Blob) => Promise<string | null>;
|
||||
}
|
||||
|
||||
export function wireTotpQr(form: HTMLElement, opts: TotpQrOpts = {}): void {
|
||||
const btn = form.querySelector<HTMLButtonElement>('#totp-qr-btn');
|
||||
const panel = form.querySelector<HTMLElement>('#totp-qr-panel');
|
||||
const fileInput = form.querySelector<HTMLInputElement>('#totp-qr-file');
|
||||
const errEl = form.querySelector<HTMLElement>('#totp-qr-error');
|
||||
const totpInput = form.querySelector<HTMLInputElement>('#f-totp');
|
||||
if (!btn || !panel || !fileInput || !errEl || !totpInput) return;
|
||||
|
||||
const decode = opts.decodeQrFromBlob ?? defaultDecodeQrFromBlob;
|
||||
|
||||
btn.addEventListener('click', () => {
|
||||
panel.hidden = !panel.hidden;
|
||||
errEl.textContent = '';
|
||||
});
|
||||
|
||||
const handleBlob = async (blob: Blob) => {
|
||||
errEl.textContent = '';
|
||||
let decoded: string | null;
|
||||
try {
|
||||
decoded = await decode(blob);
|
||||
} catch (e) {
|
||||
errEl.textContent = `decode failed: ${e instanceof Error ? e.message : String(e)}`;
|
||||
return;
|
||||
}
|
||||
if (!decoded) {
|
||||
errEl.textContent = 'no QR found in image';
|
||||
return;
|
||||
}
|
||||
if (!decoded.startsWith('otpauth://')) {
|
||||
errEl.textContent = 'not a TOTP URI (expected otpauth://...)';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const u = new URL(decoded);
|
||||
const secret = u.searchParams.get('secret');
|
||||
if (!secret) {
|
||||
errEl.textContent = 'TOTP URI missing secret';
|
||||
return;
|
||||
}
|
||||
totpInput.value = secret;
|
||||
totpInput.dispatchEvent(new Event('input', { bubbles: true })); // trigger preview
|
||||
panel.hidden = true;
|
||||
} catch {
|
||||
errEl.textContent = 'TOTP URI did not parse';
|
||||
}
|
||||
};
|
||||
|
||||
fileInput.addEventListener('change', () => {
|
||||
const f = fileInput.files?.[0];
|
||||
if (f) void handleBlob(f);
|
||||
});
|
||||
|
||||
panel.addEventListener('paste', (e) => {
|
||||
const item = Array.from((e as ClipboardEvent).clipboardData?.items ?? []).find((i) => i.type.startsWith('image/'));
|
||||
if (item) {
|
||||
const blob = item.getAsFile();
|
||||
if (blob) void handleBlob(blob);
|
||||
}
|
||||
});
|
||||
|
||||
panel.addEventListener('dragover', (e) => { e.preventDefault(); });
|
||||
panel.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
const f = (e as DragEvent).dataTransfer?.files?.[0];
|
||||
if (f) void handleBlob(f);
|
||||
});
|
||||
}
|
||||
79
extension/src/shared/form-affordances/url-tools.ts
Normal file
79
extension/src/shared/form-affordances/url-tools.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { GLYPH_FILL_FROM_TAB } from '../glyphs';
|
||||
|
||||
export interface FillFromTabOpts {
|
||||
sendMessage: (msg: { type: 'get_active_tab_url' }) => Promise<{ ok: boolean; data: { url: string; title: string } | null }>;
|
||||
}
|
||||
|
||||
export function wireFillFromTab(form: HTMLElement, opts: FillFromTabOpts): void {
|
||||
const btn = form.querySelector<HTMLButtonElement>('#fill-from-tab-btn');
|
||||
if (!btn) return;
|
||||
btn.addEventListener('click', async () => {
|
||||
const resp = await opts.sendMessage({ type: 'get_active_tab_url' });
|
||||
if (!resp.ok || !resp.data) {
|
||||
btn.disabled = true;
|
||||
btn.title = 'no active tab';
|
||||
return;
|
||||
}
|
||||
const urlEl = form.querySelector<HTMLInputElement>('#f-url');
|
||||
const titleEl = form.querySelector<HTMLInputElement>('#f-title');
|
||||
if (urlEl) urlEl.value = resp.data.url;
|
||||
if (titleEl && !titleEl.value.trim()) titleEl.value = resp.data.title;
|
||||
});
|
||||
}
|
||||
|
||||
export const FILL_FROM_TAB_BTN_HTML = `<button id="fill-from-tab-btn" class="glyph-btn" type="button" title="fill from active tab">${GLYPH_FILL_FROM_TAB}</button>`;
|
||||
|
||||
const CHIP_HUES = [
|
||||
'#5ea0c4', '#c47e5e', '#5ec47a', '#c45e9c',
|
||||
'#a3c45e', '#7e5ec4', '#c4b75e', '#5ec4c4',
|
||||
];
|
||||
|
||||
function hostnameHue(host: string): string {
|
||||
let h = 0;
|
||||
for (let i = 0; i < host.length; i++) h = (h * 31 + host.charCodeAt(i)) | 0;
|
||||
return CHIP_HUES[Math.abs(h) % CHIP_HUES.length];
|
||||
}
|
||||
|
||||
function tryParseHost(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
const candidate = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed) ? trimmed : `https://${trimmed}`;
|
||||
try {
|
||||
const u = new URL(candidate);
|
||||
const host = u.host || null;
|
||||
if (!host) return null;
|
||||
// Validate hostname contains only valid characters
|
||||
if (!/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/.test(host)) {
|
||||
return null;
|
||||
}
|
||||
return host;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function wireHostnameChip(form: HTMLElement): void {
|
||||
const input = form.querySelector<HTMLInputElement>('#f-url');
|
||||
const row = form.querySelector<HTMLElement>('#hostname-chip-row');
|
||||
if (!input || !row) return;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const update = () => {
|
||||
const host = tryParseHost(input.value);
|
||||
if (!host) {
|
||||
row.hidden = true;
|
||||
row.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
const initial = host[0]?.toUpperCase() ?? '?';
|
||||
const hue = hostnameHue(host);
|
||||
row.hidden = false;
|
||||
row.innerHTML = `<span class="hostname-chip" style="background:${hue};">${initial}</span><span class="hostname-text">${host}</span>`;
|
||||
};
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
timer = setTimeout(() => { timer = null; update(); }, 200);
|
||||
});
|
||||
update(); // initial render for prefilled values
|
||||
}
|
||||
@@ -36,6 +36,8 @@ export type PopupMessage =
|
||||
| { type: 'update_vault_settings'; settings: VaultSettings }
|
||||
| { type: 'get_blacklist' }
|
||||
| { type: 'remove_blacklist'; hostname: string }
|
||||
| { type: 'get_active_tab_url' }
|
||||
| { type: 'list_groups' }
|
||||
| { type: 'upload_attachment'; itemId: string; filename: string; mimeType: string; bytes: ArrayBuffer }
|
||||
| { type: 'download_attachment'; itemId: string; attachmentId: string }
|
||||
| { type: 'list_devices' }
|
||||
@@ -57,7 +59,8 @@ export type PopupMessage =
|
||||
newRemote: { hostType: 'gitea' | 'github'; hostUrl: string; repoPath: string; apiToken: string };
|
||||
}
|
||||
| { type: 'parse_lastpass_csv'; bytes: ArrayBuffer }
|
||||
| { type: 'import_lastpass_commit'; items: Item[] };
|
||||
| { type: 'import_lastpass_commit'; items: Item[] }
|
||||
| { type: 'preview_totp_from_secret'; secret_b32: string };
|
||||
|
||||
// --- Messages a content script may send ---
|
||||
|
||||
@@ -157,13 +160,14 @@ export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = new Set([
|
||||
'fill_credentials',
|
||||
'ack_autofill_origin', 'get_settings', 'update_settings',
|
||||
'get_vault_settings', 'update_vault_settings', 'get_blacklist',
|
||||
'remove_blacklist', 'upload_attachment', 'download_attachment',
|
||||
'remove_blacklist', 'get_active_tab_url', 'list_groups', 'upload_attachment', 'download_attachment',
|
||||
'list_devices', 'add_device', 'register_this_device', 'revoke_device',
|
||||
'list_trashed', 'restore_item', 'purge_item', 'purge_all_trash',
|
||||
'get_field_history',
|
||||
'get_session_config', 'update_session_config',
|
||||
'export_backup', 'restore_backup',
|
||||
'parse_lastpass_csv', 'import_lastpass_commit',
|
||||
'preview_totp_from_secret',
|
||||
] as PopupMessage['type'][]);
|
||||
|
||||
export interface ExportBackupResponse extends Extract<Response, { ok: true }> {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
--bg-page: #0d1117;
|
||||
--bg-pane: #161b22;
|
||||
--bg-elevated: #21262d;
|
||||
--bg-input: #161b22;
|
||||
--border-subtle: #30363d;
|
||||
|
||||
/* Text */
|
||||
@@ -1362,3 +1363,127 @@ textarea {
|
||||
.vault-lock-screen__form input {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Glyph button used by smart-input affordances. Sits inline with an input. */
|
||||
.glyph-btn {
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
padding: 0 6px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 3px;
|
||||
color: var(--text-muted);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.glyph-btn:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.glyph-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
.glyph-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.hostname-chip-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.hostname-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #0c1118;
|
||||
}
|
||||
.hostname-text {
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
.strength-bar-row {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.strength-bar {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
height: 4px;
|
||||
}
|
||||
.strength-bar > span {
|
||||
flex: 1;
|
||||
background: var(--border-subtle);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.strength-bar.s-very-weak > span.lit { background: #c75a4f; }
|
||||
.strength-bar.s-weak > span.lit { background: #c75a4f; }
|
||||
.strength-bar.s-fair > span.lit { background: #d49b3a; }
|
||||
.strength-bar.s-good > span.lit { background: #d49b3a; }
|
||||
.strength-bar.s-strong > span.lit { background: #6cb37a; }
|
||||
.strength-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.totp-preview {
|
||||
margin-top: 6px;
|
||||
padding: 6px 10px;
|
||||
border: 1px dashed var(--border-subtle);
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.totp-code {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.totp-countdown {
|
||||
font-size: 11px;
|
||||
}
|
||||
.totp-qr-panel {
|
||||
margin-top: 6px;
|
||||
padding: 10px;
|
||||
border: 1px dashed var(--border-subtle);
|
||||
border-radius: 3px;
|
||||
background: var(--bg-input);
|
||||
}
|
||||
.totp-qr-panel input[type="file"] {
|
||||
display: block;
|
||||
font-family: inherit;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.totp-qr-error {
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--danger, #c75a4f);
|
||||
}
|
||||
.notes-with-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.f-notes--mono {
|
||||
font-family: ui-monospace, "JetBrains Mono", "SF Mono", monospace !important;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user