26 Commits

Author SHA1 Message Date
adlee-was-taken
9a8cdf8e4f fix: replace all remaining emoji with monochrome glyph constants
- trash.ts TYPE_ICONS map uses GLYPH_TYPE_* constants
- field-history.ts copy button uses GLYPH_COPY
- attachments-disclosure.ts thumbnail/icon uses GLYPH_TYPE_DOCUMENT
- settings.ts sync button uses GLYPH_SYNC
- document.ts thumb/sigblock/preview use GLYPH_TYPE_DOCUMENT + GLYPH_PREVIEW
- glyphs.ts adds GLYPH_COPY, GLYPH_SYNC, GLYPH_PREVIEW
- vault.ts adds GLYPH_DEVICES import + devices sidebar nav button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:37:23 -04:00
adlee-was-taken
ade44b4ea1 feat(ext/vault): wire renderSettings / teardownSettings from settings component 2026-05-03 21:37:23 -04:00
adlee-was-taken
1d4b018f9a feat(ext/vault): bottom sheet type picker for new item 2026-05-03 21:37:23 -04:00
adlee-was-taken
882a89bedd feat(ext/vault): detail drawer — open/close state + core fields display 2026-05-03 21:37:23 -04:00
adlee-was-taken
37c20b28a6 feat(ext/vault): 3-column shell — sidebar category nav + list pane 2026-05-03 21:37:23 -04:00
adlee-was-taken
3553150a53 feat(ext/vault): 3-column layout CSS — drawer, bottom sheet, list rows, responsive 2026-05-03 21:37:23 -04:00
adlee-was-taken
b50f49b597 fix(ext/vault): replace emoji typeIcon with glyph constants 2026-05-03 21:37:23 -04:00
adlee-was-taken
1ec8965910 feat(ext): shared toast notification system 2026-05-03 21:37:23 -04:00
adlee-was-taken
ad6e4a2cd9 feat(ext/popup): polished 2-column type-picker with glyph icons 2026-05-03 21:37:23 -04:00
adlee-was-taken
b768f649a2 feat(ext/popup): empty states with glyph icons in item-list 2026-05-03 21:37:23 -04:00
adlee-was-taken
8b197a7525 fix(ext/popup): replace emoji in SETTINGS_OPTIONS with glyph constants 2026-05-03 21:37:23 -04:00
adlee-was-taken
117716f6cf fix(ext/popup): replace emoji typeIcon with glyph constants in item-list 2026-05-03 21:37:23 -04:00
adlee-was-taken
c5e8b52e12 fix(ext/popup): replace inline &#x2934; vault-tab button with GLYPH_VAULT_TAB 2026-05-03 21:37:23 -04:00
adlee-was-taken
a1b66a9147 feat(ext/glyphs): add GLYPH_VAULT_TAB and per-type icon constants 2026-05-03 21:37:23 -04:00
adlee-was-taken
934dfe05c2 Merge feature/v0.5.1-stream-c-recovery-qr: Recovery QR (Rust core + WASM + CLI + settings-security.ts + setup wizard) 2026-05-03 21:26:40 -04:00
adlee-was-taken
33d2a4a311 feat(ext/setup): wizard Style C progress track, glyph mode icons, recovery QR banner
- Replace dot-based progress indicator with colored horizontal segment track
  (completed=green, active=gold, pending=border) via renderProgressTrack()
- Add SETUP_STEP_NAMES constant for track segment titles
- Update Step 0 mode cards with glyph icons (◈ create, ⌥ attach)
- Add recovery QR banner in Step 5 (new-vault only, verifiedHandle present)
  with Generate now / Skip buttons wired in attachStep5()
- Add CSS for .setup-progress-track, .setup-progress-segment variants,
  and .recovery-qr-banner to styles.css

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:17:05 -04:00
adlee-was-taken
f17944a404 fix(core,wasm): correct QR version comment, expect msg, zeroize image_secret in closure 2026-05-03 21:09:02 -04:00
adlee-was-taken
4851857070 feat(ext/settings): settings-security.ts three-state recovery QR + devices component
- Add settings-security.ts with renderSecuritySection / teardownSecuritySection
- Three states: amber warning (no QR), green status (QR set up), modal overlay (show/print SVG)
- Device list with inline revoke; passphrase collected via prompt()
- QR payload never written to chrome.storage; only recovery_qr_generated_at timestamp stored
- Add generate_recovery_qr / unwrap_recovery_qr message types to messages.ts + POPUP_ONLY_TYPES
- Add SW handlers in popup-only.ts delegating to wasm_generate_recovery_qr / wasm_unwrap_recovery_qr
- Declare wasm_generate_recovery_qr and wasm_unwrap_recovery_qr in wasm.d.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:06:43 -04:00
adlee-was-taken
a6071b4c0c feat(cli): recovery-qr generate / unwrap subcommands
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:01:29 -04:00
adlee-was-taken
ada00895d4 feat(wasm): expose generate_recovery_qr and unwrap_recovery_qr bindings 2026-05-03 20:57:55 -04:00
adlee-was-taken
42b746f9af feat(wasm): session stores image_secret for recovery QR generation 2026-05-03 20:56:39 -04:00
adlee-was-taken
762a008171 test(core): recovery_qr roundtrip + error cases 2026-05-03 20:53:59 -04:00
adlee-was-taken
f93bce7388 chore(core): re-export recovery_qr module 2026-05-03 20:51:36 -04:00
adlee-was-taken
8eabaf5f31 feat(core): recovery_qr generate + unwrap + SVG functions 2026-05-03 20:51:33 -04:00
adlee-was-taken
04142dc116 feat(core): add derive_master_key_raw + RecoveryQr error variant 2026-05-03 20:51:29 -04:00
adlee-was-taken
8739f1f67b chore(core): add qrcode dependency for recovery QR 2026-05-03 20:48:38 -04:00
29 changed files with 1762 additions and 173 deletions

1
Cargo.lock generated
View File

@@ -2198,6 +2198,7 @@ dependencies = [
"hex", "hex",
"hmac", "hmac",
"image", "image",
"qrcode",
"rand", "rand",
"serde", "serde",
"serde_json", "serde_json",

View File

@@ -28,10 +28,10 @@ clap_complete = "4"
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] } image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
rqrr = "0.7" rqrr = "0.7"
reqwest = { version = "0.12", features = ["blocking", "json"] } reqwest = { version = "0.12", features = ["blocking", "json"] }
qrcode = { version = "0.14", features = ["svg"] }
[dev-dependencies] [dev-dependencies]
assert_cmd = "2" assert_cmd = "2"
predicates = "3" predicates = "3"
tempfile = "3" tempfile = "3"
qrcode = "0.14"
serde_json = "1" serde_json = "1"

View File

@@ -196,6 +196,12 @@ enum Commands {
#[command(subcommand)] #[command(subcommand)]
action: DeviceAction, action: DeviceAction,
}, },
/// Recovery QR operations — generate or unwrap the 2FA recovery code.
RecoveryQr {
#[command(subcommand)]
cmd: RecoveryQrCmd,
},
} }
#[derive(Subcommand)] #[derive(Subcommand)]
@@ -403,6 +409,14 @@ enum DeviceAction {
List, List,
} }
#[derive(clap::Subcommand)]
enum RecoveryQrCmd {
/// Generate a recovery QR code and display it as ASCII art in the terminal.
Generate,
/// Unwrap a recovery QR payload (base64) to recover the image_secret as hex.
Unwrap,
}
fn main() -> Result<()> { fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
match cli.command { match cli.command {
@@ -436,6 +450,7 @@ fn main() -> Result<()> {
} }
Commands::Rate { passphrase } => cmd_rate(passphrase), Commands::Rate { passphrase } => cmd_rate(passphrase),
Commands::Device { action } => cmd_device(action), Commands::Device { action } => cmd_device(action),
Commands::RecoveryQr { cmd } => cmd_recovery_qr(cmd),
} }
} }
@@ -2560,3 +2575,67 @@ fn cmd_device(action: DeviceAction) -> Result<()> {
} }
} }
} }
fn cmd_recovery_qr(cmd: RecoveryQrCmd) -> Result<()> {
match cmd {
RecoveryQrCmd::Generate => cmd_recovery_qr_generate(),
RecoveryQrCmd::Unwrap => cmd_recovery_qr_unwrap(),
}
}
fn cmd_recovery_qr_generate() -> Result<()> {
use relicario_core::{generate_recovery_qr, imgsecret};
use zeroize::Zeroizing;
let image_path = crate::session::get_image_path()?;
let image_bytes = std::fs::read(&image_path)
.with_context(|| format!("read reference image {}", image_path.display()))?;
let image_secret = imgsecret::extract(&image_bytes)
.context("extract image secret")?;
let passphrase = Zeroizing::new(
rpassword::prompt_password("Enter vault passphrase: ")
.context("read passphrase")?
);
let payload = generate_recovery_qr(passphrase.as_str(), &image_secret)
.map_err(|e| anyhow::anyhow!("{e}"))?;
use qrcode::{EcLevel, QrCode, render::unicode};
let code = QrCode::with_error_correction_level(payload.as_bytes(), EcLevel::M)
.expect("valid payload");
let image = code
.render::<unicode::Dense1x2>()
.dark_color(unicode::Dense1x2::Dark)
.light_color(unicode::Dense1x2::Light)
.build();
println!("{image}");
println!("Recovery QR generated. Print or photograph this code and store it securely.");
println!("The QR has NOT been saved to disk.");
Ok(())
}
fn cmd_recovery_qr_unwrap() -> Result<()> {
use relicario_core::unwrap_recovery_qr;
use std::io::BufRead;
use zeroize::Zeroizing;
println!("Paste the base64 recovery QR payload and press Enter:");
let stdin = std::io::stdin();
let payload_b64 = stdin.lock().lines().next()
.context("no input")??;
let payload_b64 = payload_b64.trim().to_owned();
let bytes = data_encoding::BASE64.decode(payload_b64.as_bytes())
.map_err(|e| anyhow::anyhow!("base64 decode: {e}"))?;
let passphrase = Zeroizing::new(
rpassword::prompt_password("Enter passphrase: ")
.context("read passphrase")?
);
let secret = unwrap_recovery_qr(&bytes, passphrase.as_str())
.map_err(|e| anyhow::anyhow!("{e}"))?;
println!("image_secret: {}", hex::encode(secret.as_ref()));
Ok(())
}

View File

@@ -31,5 +31,6 @@ zstd = { version = "0.13", default-features = false }
tar = { version = "0.4", default-features = false } tar = { version = "0.4", default-features = false }
base64 = "0.22" base64 = "0.22"
csv = "1" csv = "1"
qrcode = { version = "0.14", default-features = false, features = ["svg"] }
[dev-dependencies] [dev-dependencies]

View File

@@ -243,6 +243,23 @@ pub fn derive_master_key(
Ok(output) Ok(output)
} }
/// Like `derive_master_key` but takes an already-assembled `input` byte slice directly,
/// allowing callers to apply their own domain separation before KDF.
pub fn derive_master_key_raw(
input: &[u8],
salt: &[u8; 32],
params: &KdfParams,
) -> Result<Zeroizing<[u8; 32]>> {
let argon2_params = Params::new(params.argon2_m, params.argon2_t, params.argon2_p, Some(32))
.map_err(|e| RelicarioError::Kdf(e.to_string()))?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
let mut output = Zeroizing::new([0u8; 32]);
argon2
.hash_password_into(input, salt, output.as_mut())
.map_err(|e| RelicarioError::Kdf(e.to_string()))?;
Ok(output)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@@ -119,6 +119,10 @@ pub enum RelicarioError {
/// immediately. Use TOTP instead. /// immediately. Use TOTP instead.
#[error("HOTP is not supported: counter persistence requires vault save after each use")] #[error("HOTP is not supported: counter persistence requires vault save after each use")]
HotpNotSupported, HotpNotSupported,
/// Recovery QR generation or parsing failed.
#[error("recovery QR: {0}")]
RecoveryQr(String),
} }
/// Crate-wide result alias, reducing boilerplate in function signatures. /// Crate-wide result alias, reducing boilerplate in function signatures.

View File

@@ -89,3 +89,11 @@ pub use device::{fingerprint, DeviceEntry, RevokedEntry, generate_keypair, sign,
pub mod tar_safe; pub mod tar_safe;
pub use tar_safe::{safe_unpack_git_archive, DEFAULT_MAX_UNCOMPRESSED}; pub use tar_safe::{safe_unpack_git_archive, DEFAULT_MAX_UNCOMPRESSED};
pub mod recovery_qr;
pub use recovery_qr::{
generate_recovery_qr, generate_recovery_qr_with_params,
recovery_qr_to_svg,
unwrap_recovery_qr, unwrap_recovery_qr_with_params,
RecoveryQrPayload,
};

View File

@@ -0,0 +1,129 @@
use chacha20poly1305::{XChaCha20Poly1305, Key, KeyInit, aead::Aead};
use rand::RngCore;
use unicode_normalization::UnicodeNormalization;
use zeroize::Zeroizing;
use crate::{crypto::KdfParams, error::{RelicarioError, Result}};
const MAGIC: &[u8; 4] = b"RREC";
const VERSION: u8 = 0x01;
const PAYLOAD_LEN: usize = 4 + 1 + 32 + 24 + 48; // 109
pub struct RecoveryQrPayload {
bytes: [u8; PAYLOAD_LEN],
}
impl RecoveryQrPayload {
pub fn as_bytes(&self) -> &[u8; PAYLOAD_LEN] {
&self.bytes
}
}
fn recovery_kdf_input(passphrase: &str) -> Vec<u8> {
let nfc: String = passphrase.nfc().collect();
let nfc_bytes = nfc.as_bytes();
let prefix = b"relicario-recovery-v1\0";
let mut input = Vec::with_capacity(prefix.len() + 8 + nfc_bytes.len());
input.extend_from_slice(prefix);
input.extend_from_slice(&(nfc_bytes.len() as u64).to_be_bytes());
input.extend_from_slice(nfc_bytes);
input
}
fn production_params() -> KdfParams {
KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 }
}
fn derive_wrap_key(
passphrase: &str,
kdf_salt: &[u8; 32],
params: &KdfParams,
) -> Result<Zeroizing<[u8; 32]>> {
let input = recovery_kdf_input(passphrase);
crate::crypto::derive_master_key_raw(&input, kdf_salt, params)
}
pub fn generate_recovery_qr(
passphrase: &str,
image_secret: &[u8; 32],
) -> Result<RecoveryQrPayload> {
generate_recovery_qr_with_params(passphrase, image_secret, &production_params())
}
#[doc(hidden)]
pub fn generate_recovery_qr_with_params(
passphrase: &str,
image_secret: &[u8; 32],
params: &KdfParams,
) -> Result<RecoveryQrPayload> {
let mut kdf_salt = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut kdf_salt);
let mut wrap_nonce = [0u8; 24];
rand::rngs::OsRng.fill_bytes(&mut wrap_nonce);
let wrap_key = derive_wrap_key(passphrase, &kdf_salt, params)?;
let cipher = XChaCha20Poly1305::new(Key::from_slice(wrap_key.as_ref()));
let nonce = chacha20poly1305::XNonce::from_slice(&wrap_nonce);
let ciphertext = cipher.encrypt(nonce, image_secret.as_ref())
.map_err(|_| RelicarioError::RecoveryQr("wrap encrypt failed".into()))?;
let mut bytes = [0u8; PAYLOAD_LEN];
let mut pos = 0;
bytes[pos..pos+4].copy_from_slice(MAGIC); pos += 4;
bytes[pos] = VERSION; pos += 1;
bytes[pos..pos+32].copy_from_slice(&kdf_salt); pos += 32;
bytes[pos..pos+24].copy_from_slice(&wrap_nonce); pos += 24;
bytes[pos..pos+48].copy_from_slice(&ciphertext);
Ok(RecoveryQrPayload { bytes })
}
pub fn unwrap_recovery_qr(
payload_bytes: &[u8],
passphrase: &str,
) -> Result<Zeroizing<[u8; 32]>> {
unwrap_recovery_qr_with_params(payload_bytes, passphrase, &production_params())
}
#[doc(hidden)]
pub fn unwrap_recovery_qr_with_params(
payload_bytes: &[u8],
passphrase: &str,
params: &KdfParams,
) -> Result<Zeroizing<[u8; 32]>> {
if payload_bytes.len() != PAYLOAD_LEN {
return Err(RelicarioError::RecoveryQr(
format!("payload must be {PAYLOAD_LEN} bytes, got {}", payload_bytes.len())
));
}
if &payload_bytes[0..4] != MAGIC {
return Err(RelicarioError::RecoveryQr("bad magic".into()));
}
if payload_bytes[4] != VERSION {
return Err(RelicarioError::RecoveryQr(
format!("unsupported version 0x{:02x}", payload_bytes[4])
));
}
let kdf_salt: &[u8; 32] = payload_bytes[5..37].try_into().expect("slice length validated above");
let wrap_nonce = &payload_bytes[37..61];
let ciphertext = &payload_bytes[61..109];
let wrap_key = derive_wrap_key(passphrase, kdf_salt, params)?;
let cipher = XChaCha20Poly1305::new(Key::from_slice(wrap_key.as_ref()));
let nonce = chacha20poly1305::XNonce::from_slice(wrap_nonce);
let plaintext = cipher.decrypt(nonce, ciphertext)
.map_err(|_| RelicarioError::Decrypt)?;
let mut out = Zeroizing::new([0u8; 32]);
out.copy_from_slice(&plaintext);
Ok(out)
}
pub fn recovery_qr_to_svg(payload: &RecoveryQrPayload) -> String {
use qrcode::{QrCode, EcLevel};
let code = QrCode::with_error_correction_level(payload.bytes.as_ref(), EcLevel::M)
.expect("109 bytes fits well within QR v40 capacity at EcLevel::M");
code.render::<qrcode::render::svg::Color>()
.min_dimensions(140, 140)
.build()
}

View File

@@ -0,0 +1,60 @@
use relicario_core::{
crypto::KdfParams,
generate_recovery_qr_with_params, recovery_qr_to_svg, unwrap_recovery_qr_with_params,
};
fn fast_params() -> KdfParams {
KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 }
}
fn test_secret() -> [u8; 32] {
let mut s = [0u8; 32];
for (i, b) in s.iter_mut().enumerate() { *b = i as u8; }
s
}
#[test]
fn roundtrip_recovers_image_secret() {
let passphrase = "correct-horse-battery-staple";
let secret = test_secret();
let payload = generate_recovery_qr_with_params(passphrase, &secret, &fast_params())
.expect("generate ok");
let recovered = unwrap_recovery_qr_with_params(payload.as_bytes(), passphrase, &fast_params())
.expect("unwrap ok");
assert_eq!(recovered.as_ref(), &secret);
}
#[test]
fn wrong_passphrase_fails_decrypt() {
let secret = test_secret();
let payload = generate_recovery_qr_with_params("right-pass", &secret, &fast_params())
.expect("generate ok");
let result = unwrap_recovery_qr_with_params(payload.as_bytes(), "wrong-pass", &fast_params());
assert!(result.is_err());
}
#[test]
fn payload_is_109_bytes() {
let secret = test_secret();
let payload = generate_recovery_qr_with_params("test", &secret, &fast_params())
.expect("generate ok");
assert_eq!(payload.as_bytes().len(), 109);
}
#[test]
fn svg_output_is_non_empty_xml() {
let secret = test_secret();
let payload = generate_recovery_qr_with_params("test", &secret, &fast_params())
.expect("generate ok");
let svg = recovery_qr_to_svg(&payload);
assert!(svg.contains("<svg"), "SVG output should contain <svg tag");
assert!(!svg.is_empty());
}
#[test]
fn bad_magic_returns_error() {
let mut bad = [0u8; 109];
bad[0..4].copy_from_slice(b"NOPE");
let result = unwrap_recovery_qr_with_params(&bad, "pass", &fast_params());
assert!(result.is_err());
}

View File

@@ -8,6 +8,7 @@ mod session;
mod device; mod device;
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use zeroize::Zeroizing;
use relicario_core::{derive_master_key, imgsecret, KdfParams}; use relicario_core::{derive_master_key, imgsecret, KdfParams};
@@ -36,7 +37,8 @@ pub fn unlock(
.map_err(|_| JsError::new("salt must be exactly 32 bytes"))?; .map_err(|_| JsError::new("salt must be exactly 32 bytes"))?;
let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, salt_arr, &params) let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, salt_arr, &params)
.map_err(|e| JsError::new(&e.to_string()))?; .map_err(|e| JsError::new(&e.to_string()))?;
let handle = session::insert(master_key); let stored_secret = Zeroizing::new(image_secret);
let handle = session::insert(master_key, stored_secret);
Ok(SessionHandle(handle)) Ok(SessionHandle(handle))
} }
@@ -484,6 +486,39 @@ pub fn parse_lastpass_csv_json(csv_bytes: &[u8]) -> Result<String, JsError> {
Ok(json.to_string()) Ok(json.to_string())
} }
// ── Recovery QR bindings ─────────────────────────────────────────────────────
use relicario_core::{generate_recovery_qr, recovery_qr_to_svg, unwrap_recovery_qr};
/// Generate a recovery QR SVG for the current session.
/// Returns the SVG string. The passphrase wraps the image_secret under a
/// separate key (domain-separated from the master key derivation).
#[wasm_bindgen]
pub fn wasm_generate_recovery_qr(
handle: &SessionHandle,
passphrase: &str,
) -> Result<String, JsError> {
let payload = session::with_image_secret(handle.0, |s| generate_recovery_qr(passphrase, s))
.ok_or_else(|| JsError::new("invalid or locked session handle"))?
.map_err(|e| JsError::new(&e.to_string()))?;
Ok(recovery_qr_to_svg(&payload))
}
/// Unwrap a recovery QR payload (base64-encoded 109-byte blob) using the passphrase.
/// Returns the raw image_secret bytes (32 bytes).
#[wasm_bindgen]
pub fn wasm_unwrap_recovery_qr(
payload_b64: &str,
passphrase: &str,
) -> Result<Vec<u8>, JsError> {
use base64::{engine::general_purpose::STANDARD, Engine};
let bytes = STANDARD.decode(payload_b64)
.map_err(|e| JsError::new(&format!("base64: {e}")))?;
let recovered = unwrap_recovery_qr(&bytes, passphrase)
.map_err(|e| JsError::new(&e.to_string()))?;
Ok(recovered.to_vec())
}
#[cfg(test)] #[cfg(test)]
mod session_tests { mod session_tests {
use super::*; use super::*;
@@ -492,7 +527,7 @@ mod session_tests {
#[test] #[test]
fn insert_then_remove_clears_entry() { fn insert_then_remove_clears_entry() {
session::clear(); session::clear();
let h = session::insert(Zeroizing::new([0x11u8; 32])); let h = session::insert(Zeroizing::new([0x11u8; 32]), Zeroizing::new([0u8; 32]));
assert_ne!(h, 0); assert_ne!(h, 0);
assert!(session::remove(h)); assert!(session::remove(h));
assert!(!session::remove(h)); // second remove false assert!(!session::remove(h)); // second remove false
@@ -501,7 +536,7 @@ mod session_tests {
#[test] #[test]
fn with_yields_key_only_while_session_lives() { fn with_yields_key_only_while_session_lives() {
session::clear(); session::clear();
let h = session::insert(Zeroizing::new([0x22u8; 32])); let h = session::insert(Zeroizing::new([0x22u8; 32]), Zeroizing::new([0u8; 32]));
let byte = session::with(h, |k| k[0]); let byte = session::with(h, |k| k[0]);
assert_eq!(byte, Some(0x22)); assert_eq!(byte, Some(0x22));
session::remove(h); session::remove(h);
@@ -513,7 +548,7 @@ mod session_tests {
fn manifest_round_trip_via_handle() { fn manifest_round_trip_via_handle() {
use relicario_core::{Manifest, decrypt_manifest}; use relicario_core::{Manifest, decrypt_manifest};
session::clear(); session::clear();
let h = session::insert(Zeroizing::new([0x55u8; 32])); let h = session::insert(Zeroizing::new([0x55u8; 32]), Zeroizing::new([0u8; 32]));
let handle = SessionHandle(h); let handle = SessionHandle(h);
let key = Zeroizing::new([0x55u8; 32]); let key = Zeroizing::new([0x55u8; 32]);
let empty = Manifest::new(); let empty = Manifest::new();

View File

@@ -6,12 +6,17 @@ use std::cell::RefCell;
use std::collections::HashMap; use std::collections::HashMap;
use zeroize::Zeroizing; use zeroize::Zeroizing;
pub struct SessionData {
pub master_key: Zeroizing<[u8; 32]>,
pub image_secret: Zeroizing<[u8; 32]>,
}
thread_local! { thread_local! {
static SESSIONS: RefCell<HashMap<u32, Zeroizing<[u8; 32]>>> = RefCell::new(HashMap::new()); static SESSIONS: RefCell<HashMap<u32, SessionData>> = RefCell::new(HashMap::new());
static NEXT_HANDLE: RefCell<u32> = const { RefCell::new(1) }; static NEXT_HANDLE: RefCell<u32> = const { RefCell::new(1) };
} }
pub fn insert(key: Zeroizing<[u8; 32]>) -> u32 { pub fn insert(master_key: Zeroizing<[u8; 32]>, image_secret: Zeroizing<[u8; 32]>) -> u32 {
let handle = NEXT_HANDLE.with(|n| { let handle = NEXT_HANDLE.with(|n| {
let mut n = n.borrow_mut(); let mut n = n.borrow_mut();
let h = *n; let h = *n;
@@ -19,15 +24,26 @@ pub fn insert(key: Zeroizing<[u8; 32]>) -> u32 {
if *n == 0 { *n = 1; } // avoid reserving 0 as a valid handle if *n == 0 { *n = 1; } // avoid reserving 0 as a valid handle
h h
}); });
SESSIONS.with(|s| { s.borrow_mut().insert(handle, key); }); SESSIONS.with(|s| {
s.borrow_mut().insert(handle, SessionData { master_key, image_secret });
});
handle handle
} }
/// Access the master key for a handle. Preserves original `with` signature for all existing callers.
pub fn with<F, R>(handle: u32, f: F) -> Option<R> pub fn with<F, R>(handle: u32, f: F) -> Option<R>
where where
F: FnOnce(&Zeroizing<[u8; 32]>) -> R, F: FnOnce(&Zeroizing<[u8; 32]>) -> R,
{ {
SESSIONS.with(|s| s.borrow().get(&handle).map(f)) SESSIONS.with(|s| s.borrow().get(&handle).map(|d| f(&d.master_key)))
}
/// Access the image_secret for a handle (used by recovery QR).
pub fn with_image_secret<F, R>(handle: u32, f: F) -> Option<R>
where
F: FnOnce(&Zeroizing<[u8; 32]>) -> R,
{
SESSIONS.with(|s| s.borrow().get(&handle).map(|d| f(&d.image_secret)))
} }
pub fn remove(handle: u32) -> bool { pub fn remove(handle: u32) -> bool {

View File

@@ -7,6 +7,7 @@
import { sendMessage, escapeHtml } from '../../shared/state'; import { sendMessage, escapeHtml } from '../../shared/state';
import type { AttachmentRef, VaultSettings } from '../../shared/types'; import type { AttachmentRef, VaultSettings } from '../../shared/types';
import { GLYPH_TYPE_DOCUMENT } from '../../shared/glyphs';
export type DisclosureMode = 'edit' | 'view'; export type DisclosureMode = 'edit' | 'view';
@@ -53,8 +54,8 @@ export function renderAttachmentsDisclosure(opts: AttachmentsDisclosureOpts): st
const action = opts.mode === 'edit' ? '×' : '↓'; const action = opts.mode === 'edit' ? '×' : '↓';
const actionClass = opts.mode === 'edit' ? 'attachment-row__remove' : 'attachment-row__download'; const actionClass = opts.mode === 'edit' ? 'attachment-row__remove' : 'attachment-row__download';
const iconHtml = isImage(a.mime_type) const iconHtml = isImage(a.mime_type)
? `<span class="attachment-row__thumb" data-att-id="${escapeHtml(a.id)}" data-mime="${escapeHtml(a.mime_type)}">📄</span>` ? `<span class="attachment-row__thumb" data-att-id="${escapeHtml(a.id)}" data-mime="${escapeHtml(a.mime_type)}">${GLYPH_TYPE_DOCUMENT}</span>`
: `<span class="attachment-row__icon">📄</span>`; : `<span class="attachment-row__icon">${GLYPH_TYPE_DOCUMENT}</span>`;
return ` return `
<div class="attachment-row" data-att-id="${escapeHtml(a.id)}"> <div class="attachment-row" data-att-id="${escapeHtml(a.id)}">
${iconHtml} ${iconHtml}

View File

@@ -3,6 +3,7 @@
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../shared/state'; import { getState, setState, sendMessage, navigate, escapeHtml } from '../../shared/state';
import { colorizePassword } from '../../shared/password-coloring'; import { colorizePassword } from '../../shared/password-coloring';
import type { FieldHistoryView } from '../../shared/types'; import type { FieldHistoryView } from '../../shared/types';
import { GLYPH_COPY } from '../../shared/glyphs';
function relativeTime(unixSec: number): string { function relativeTime(unixSec: number): string {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
@@ -75,7 +76,7 @@ export async function renderFieldHistory(app: HTMLElement): Promise<void> {
${isCurrent ? '<span class="history-entry__current">current</span>' : ''} ${isCurrent ? '<span class="history-entry__current">current</span>' : ''}
<span>${isCurrent ? 'set' : 'changed'} ${relativeTime(timestamp)}</span> <span>${isCurrent ? 'set' : 'changed'} ${relativeTime(timestamp)}</span>
</div> </div>
<button class="history-entry__copy" data-entry-copy="${escapeHtml(entryKey)}" title="Copy">📋</button> <button class="history-entry__copy" data-entry-copy="${escapeHtml(entryKey)}" title="Copy">${GLYPH_COPY}</button>
</div> </div>
`; `;
} }
@@ -140,7 +141,7 @@ export async function renderFieldHistory(app: HTMLElement): Promise<void> {
const value = valueStore.get(key) ?? ''; const value = valueStore.get(key) ?? '';
await navigator.clipboard.writeText(value); await navigator.clipboard.writeText(value);
btn.textContent = '✓'; btn.textContent = '✓';
setTimeout(() => { btn.textContent = '📋'; }, 1500); setTimeout(() => { btn.textContent = GLYPH_COPY; }, 1500);
}); });
}); });
} }

View File

@@ -3,15 +3,19 @@
import { navigate, getState, setState, escapeHtml, popOutToTab, isInTab } from '../../shared/state'; import { navigate, getState, setState, escapeHtml, popOutToTab, isInTab } from '../../shared/state';
import type { Item, ItemType } from '../../shared/types'; import type { Item, ItemType } from '../../shared/types';
import {
GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_TOTP,
GLYPH_TYPE_CARD, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT,
} from '../../shared/glyphs';
const TYPE_OPTIONS: Array<{ type: ItemType; icon: string; label: string }> = [ const TYPE_OPTIONS: Array<{ type: ItemType; icon: string; label: string; description: string }> = [
{ type: 'login', icon: '🔑', label: 'login' }, { type: 'login', icon: GLYPH_TYPE_LOGIN, label: 'Login', description: 'Username + password' },
{ type: 'secure_note', icon: '📝', label: 'secure note' }, { type: 'secure_note', icon: GLYPH_TYPE_SECURE_NOTE, label: 'Secure Note', description: 'Encrypted text note' },
{ type: 'identity', icon: '👤', label: 'identity' }, { type: 'identity', icon: GLYPH_TYPE_IDENTITY, label: 'Identity', description: 'Personal details' },
{ type: 'card', icon: '💳', label: 'card' }, { type: 'card', icon: GLYPH_TYPE_CARD, label: 'Card', description: 'Credit / debit card' },
{ type: 'key', icon: '🔐', label: 'key' }, { type: 'key', icon: GLYPH_TYPE_KEY, label: 'SSH / API Key', description: 'Keys and tokens' },
{ type: 'document', icon: '📄', label: 'document' }, { type: 'document', icon: GLYPH_TYPE_DOCUMENT, label: 'Document', description: 'File attachment' },
{ type: 'totp', icon: '⏱️', label: 'totp' }, { type: 'totp', icon: GLYPH_TYPE_TOTP, label: 'TOTP', description: '2FA authenticator' },
]; ];
import * as login from './types/login'; import * as login from './types/login';
import * as secureNote from './types/secure-note'; import * as secureNote from './types/secure-note';
@@ -54,36 +58,36 @@ export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void {
function renderTypeSelection(app: HTMLElement): void { function renderTypeSelection(app: HTMLElement): void {
app.innerHTML = ` app.innerHTML = `
<div class="pad"> <div class="pad">
<div style="display:flex; align-items:center; gap:12px;"> <div style="display:flex; align-items:center; gap:12px; margin-bottom:12px;">
<button class="btn" id="back-btn"> back</button> <button class="btn" id="back-btn"> back</button>
<h3 style="margin:0;">new item</h3> <span style="font-size:14px; font-weight:600;">New item</span>
<span style="flex:1;"></span> <span style="flex:1;"></span>
${isInTab() ? '' : '<button class="btn" id="popout-btn" title="Open in tab"></button>'} ${isInTab() ? '' : '<button class="btn" id="popout-btn" title="Open in tab"></button>'}
</div> </div>
${isInTab() ? '<div class="form-subtitle">esc to cancel</div>' : '<div style="margin-bottom:16px;"></div>'} <div class="type-card-grid">
<div class="type-select-list">
${TYPE_OPTIONS.map((opt) => ` ${TYPE_OPTIONS.map((opt) => `
<button class="type-select-row" data-type="${opt.type}"> <button class="type-card" data-type="${opt.type}">
<span class="type-select-icon">${opt.icon}</span> <span class="type-card__icon" aria-hidden="true">${opt.icon}</span>
<span>${escapeHtml(opt.label)}</span> <span class="type-card__label">${escapeHtml(opt.label)}</span>
<span class="type-card__desc">${escapeHtml(opt.description)}</span>
</button> </button>
`).join('')} `).join('')}
</div> </div>
<div class="keyhints"><span><kbd>Esc</kbd> back</span></div>
</div> </div>
`; `;
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list')); document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
document.getElementById('popout-btn')?.addEventListener('click', popOutToTab); document.getElementById('popout-btn')?.addEventListener('click', popOutToTab);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') navigate('list');
}, { once: true });
document.querySelectorAll<HTMLButtonElement>('[data-type]').forEach((btn) => { document.querySelectorAll<HTMLButtonElement>('[data-type]').forEach((btn) => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
const type = btn.dataset.type as ItemType; const type = btn.dataset.type as ItemType;
setState({ newType: type }); setState({ newType: type });
if (type === 'login' || type === 'secure_note') { renderItemForm(app, 'add');
renderItemForm(app, 'add');
} else {
popOutToTab();
}
}); });
}); });
} }

View File

@@ -3,6 +3,13 @@
/// to the detail view. /// to the detail view.
import { getState, setState, sendMessage, navigate, escapeHtml, openVaultTab } from '../../shared/state'; import { getState, setState, sendMessage, navigate, escapeHtml, openVaultTab } from '../../shared/state';
import { showToast } from '../../shared/toast';
import {
GLYPH_VAULT_TAB,
GLYPH_DEVICES, GLYPH_LOCK,
GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_TOTP,
GLYPH_TYPE_CARD, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT,
} from '../../shared/glyphs';
import type { ItemId, ItemType, ManifestEntry, Item } from '../../shared/types'; import type { ItemId, ItemType, ManifestEntry, Item } from '../../shared/types';
/// Extract the display hostname from an icon_hint or fallback to the first tag. /// Extract the display hostname from an icon_hint or fallback to the first tag.
@@ -12,30 +19,46 @@ function metaLine(e: ManifestEntry): string {
return ''; return '';
} }
/// Emoji icon per item type. Placeholder until we ship real SVG icons. /// Glyph icon per item type.
function typeIcon(t: ItemType): string { function typeIcon(t: ItemType): string {
switch (t) { switch (t) {
case 'login': return '🔑'; case 'login': return GLYPH_TYPE_LOGIN;
case 'secure_note': return '📝'; case 'secure_note': return GLYPH_TYPE_SECURE_NOTE;
case 'identity': return '🪪'; case 'identity': return GLYPH_TYPE_IDENTITY;
case 'card': return '💳'; case 'card': return GLYPH_TYPE_CARD;
case 'key': return '🗝'; case 'key': return GLYPH_TYPE_KEY;
case 'document': return '📄'; case 'document': return GLYPH_TYPE_DOCUMENT;
case 'totp': return '⏱'; case 'totp': return GLYPH_TYPE_TOTP;
} }
} }
function buildRowsHtml(): string { function buildRowsHtml(): string {
const state = getState(); const state = getState();
const filtered = getFilteredEntries(); const filtered = getFilteredEntries();
return filtered.length > 0 if (filtered.length > 0) {
? filtered.map(([id, e], i) => ` return filtered.map(([id, e], i) => `
<div class="entry-row ${i === state.selectedIndex ? 'selected' : ''}" data-id="${escapeHtml(id)}" data-index="${i}"> <div class="entry-row ${i === state.selectedIndex ? 'selected' : ''}" data-id="${escapeHtml(id)}" data-index="${i}">
<span class="entry-name"><span class="type-icon" aria-hidden="true">${typeIcon(e.type)}</span> ${escapeHtml(e.title)}${e.attachment_summaries.length > 0 ? ' <span class="entry-row__attach-indicator" title="has attachments">📎</span>' : ''}</span> <span class="entry-name"><span class="type-icon" aria-hidden="true">${typeIcon(e.type)}</span> ${escapeHtml(e.title)}${e.attachment_summaries.length > 0 ? ' <span class="entry-row__attach-indicator" title="has attachments"></span>' : ''}</span>
<span class="entry-meta">${escapeHtml(metaLine(e))}</span> <span class="entry-meta">${escapeHtml(metaLine(e))}</span>
</div> </div>
`).join('') `).join('');
: '<div class="empty">no items</div>'; }
if (state.searchQuery) {
return `
<div class="empty-state">
<span class="empty-state__icon" aria-hidden="true">⊘</span>
<div class="empty-state__title">No results for "${escapeHtml(state.searchQuery)}"</div>
<div class="empty-state__hint">Try a shorter search term.</div>
</div>
`;
}
return `
<div class="empty-state">
<span class="empty-state__icon" aria-hidden="true">◈</span>
<div class="empty-state__title">No items yet</div>
<div class="empty-state__hint">Press <kbd>+</kbd> to add your first item.</div>
</div>
`;
} }
function updateItemList(): void { function updateItemList(): void {
@@ -66,7 +89,7 @@ export function renderItemList(app: HTMLElement): void {
<button class="btn" id="new-btn" style="font-size:11px;">+ new</button> <button class="btn" id="new-btn" style="font-size:11px;">+ new</button>
<button class="btn" id="sync-btn" style="font-size:11px;">sync</button> <button class="btn" id="sync-btn" style="font-size:11px;">sync</button>
<span style="flex:1;"></span> <span style="flex:1;"></span>
<button class="btn" id="vault-btn" style="font-size:11px;" title="Open vault (Shift+F)">&#x2934;</button> <button class="btn" id="vault-btn" style="font-size:11px;" title="Open vault (Shift+F)">${GLYPH_VAULT_TAB}</button>
<button class="btn" id="settings-btn" style="font-size:11px;">settings</button> <button class="btn" id="settings-btn" style="font-size:11px;">settings</button>
<button class="btn" id="lock-btn" style="font-size:11px;">lock</button> <button class="btn" id="lock-btn" style="font-size:11px;">lock</button>
</div> </div>
@@ -108,11 +131,14 @@ export function renderItemList(app: HTMLElement): void {
if (listResp.ok) { if (listResp.ok) {
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> }; const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
setState({ entries: data.items, loading: false }); setState({ entries: data.items, loading: false });
showToast('Synced', 'success');
return; return;
} }
setState({ loading: false, error: listResp.error }); setState({ loading: false, error: listResp.error });
showToast(listResp.error ?? 'Sync failed', 'error');
} else { } else {
setState({ loading: false, error: resp.error }); setState({ loading: false, error: resp.error });
showToast(resp.error ?? 'Sync failed', 'error');
} }
}); });
@@ -253,8 +279,8 @@ function handleListKeydown(e: KeyboardEvent): void {
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
const SETTINGS_OPTIONS: Array<{ view: 'settings' | 'settings-vault'; icon: string; label: string }> = [ const SETTINGS_OPTIONS: Array<{ view: 'settings' | 'settings-vault'; icon: string; label: string }> = [
{ view: 'settings', icon: '🖥', label: 'device settings' }, { view: 'settings', icon: GLYPH_DEVICES, label: 'device settings' },
{ view: 'settings-vault', icon: '🔐', label: 'vault settings' }, { view: 'settings-vault', icon: GLYPH_LOCK, label: 'vault settings' },
]; ];
function showSettingsPicker(anchor: HTMLElement): void { function showSettingsPicker(anchor: HTMLElement): void {

View File

@@ -0,0 +1,329 @@
/// Security settings section — three-state Recovery QR + Trusted Devices panel.
///
/// Exported contract:
/// renderSecuritySection(container, sessionHandle): renders into `container`
/// teardownSecuritySection(): removes any open QR modal
import { sendMessage, escapeHtml } from '../../shared/state';
import type { Device } from '../../shared/types';
// --- Relative time helper ---
function relativeTime(unixSec: number): string {
const now = Math.floor(Date.now() / 1000);
const diff = now - unixSec;
if (diff < 60) return 'just now';
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
if (diff < 2592000) return `${Math.floor(diff / 86400)}d ago`;
return `${Math.floor(diff / 2592000)}mo ago`;
}
// --- Modal helpers ---
const MODAL_ID = 'relicario-qr-modal';
function removeModal(): void {
document.getElementById(MODAL_ID)?.remove();
}
function showQrModal(svgContent: string): void {
removeModal();
const overlay = document.createElement('div');
overlay.id = MODAL_ID;
overlay.style.cssText = [
'position:fixed', 'inset:0', 'z-index:9999',
'background:rgba(0,0,0,0.85)',
'display:flex', 'flex-direction:column',
'align-items:center', 'justify-content:center',
'padding:16px', 'box-sizing:border-box',
].join(';');
overlay.innerHTML = `
<div style="
background:#161b22; border:1px solid #30363d; border-radius:8px;
padding:16px; max-width:340px; width:100%; text-align:center;
">
<div style="font-size:13px; font-weight:600; margin-bottom:8px; color:#e6edf3;">
Recovery QR
</div>
<div style="font-size:11px; color:#8b949e; margin-bottom:12px;">
Print or store this QR. It encodes your reference image secret,
protected by your passphrase.
</div>
<div id="relicario-qr-svg" style="
background:#fff; border-radius:4px; padding:8px;
display:inline-block; max-width:280px; width:100%;
">
${svgContent}
</div>
<div style="display:flex; gap:8px; margin-top:12px; justify-content:center;">
<button id="relicario-qr-print" class="btn btn-primary" style="font-size:12px;">
Print
</button>
<button id="relicario-qr-done" class="btn" style="font-size:12px;">
Done
</button>
</div>
</div>
`;
document.body.appendChild(overlay);
document.getElementById('relicario-qr-done')?.addEventListener('click', removeModal);
document.getElementById('relicario-qr-print')?.addEventListener('click', () => {
const win = window.open('', '_blank', 'width=400,height=500');
if (!win) return;
win.document.write(`
<!DOCTYPE html>
<html><head><title>Recovery QR</title>
<style>
body { margin: 0; display: flex; flex-direction: column; align-items: center;
font-family: sans-serif; padding: 24px; }
h2 { font-size: 16px; margin-bottom: 8px; }
p { font-size: 12px; color: #555; margin-bottom: 16px; text-align: center; }
svg { max-width: 280px; width: 100%; }
</style></head><body>
<h2>Relicario Recovery QR</h2>
<p>Scan with the Relicario app to recover your reference image secret.<br>
Keep this page in a safe physical location.</p>
${svgContent}
<script>window.onload = () => { window.print(); window.close(); }<\/script>
</body></html>
`);
win.document.close();
});
// Close on backdrop click
overlay.addEventListener('click', (e) => {
if (e.target === overlay) removeModal();
});
}
// --- Main render ---
export async function renderSecuritySection(
container: HTMLElement,
sessionHandle: number | null,
): Promise<void> {
// Read timestamp from device-local storage (never the QR payload itself)
const stored = await chrome.storage.local.get(['recovery_qr_generated_at']);
const generatedAt: number | null = (stored.recovery_qr_generated_at as number) ?? null;
const isUnlocked = sessionHandle !== null;
// --- QR status section ---
let qrStatusHtml: string;
if (generatedAt === null) {
qrStatusHtml = `
<div style="
display:flex; align-items:flex-start; gap:10px;
background:#2d1f00; border:1px solid #7c5719; border-radius:6px;
padding:10px; margin-bottom:12px;
">
<span style="font-size:16px;">⚠</span>
<div style="flex:1; font-size:12px;">
<div style="color:#e3a726; font-weight:600; margin-bottom:2px;">
No recovery QR generated
</div>
<div style="color:#8b949e;">
If you lose access to your reference image, you will be locked out permanently.
</div>
</div>
</div>
<button
class="btn btn-primary"
id="sec-generate-qr"
${isUnlocked ? '' : 'disabled title="Unlock the vault first"'}
style="width:100%; font-size:12px; margin-bottom:4px;"
>
Generate recovery QR…
</button>
`;
} else {
qrStatusHtml = `
<div style="
display:flex; align-items:flex-start; gap:10px;
background:#0a2a1a; border:1px solid #238636; border-radius:6px;
padding:10px; margin-bottom:12px;
">
<span style="font-size:16px;">✓</span>
<div style="flex:1; font-size:12px;">
<div style="color:#3fb950; font-weight:600; margin-bottom:2px;">
Recovery QR set up
</div>
<div style="color:#8b949e;">
Generated ${relativeTime(generatedAt)}. Store the printout in a safe place.
</div>
</div>
</div>
<div style="display:flex; gap:8px; margin-bottom:4px;">
<button
class="btn"
id="sec-show-qr"
${isUnlocked ? '' : 'disabled title="Unlock the vault first"'}
style="flex:1; font-size:12px;"
>
Show / print QR…
</button>
<button
class="btn"
id="sec-regenerate-qr"
${isUnlocked ? '' : 'disabled title="Unlock the vault first"'}
style="flex:1; font-size:12px;"
>
Regenerate…
</button>
</div>
`;
}
// --- Devices section ---
const devicesResp = await sendMessage({ type: 'list_devices' });
let devicesHtml: string;
if (!devicesResp.ok) {
devicesHtml = `<p class="muted" style="font-size:12px;">Could not load devices.</p>`;
} else {
const devices = (devicesResp.data as { devices: Device[] }).devices;
const currentDeviceNameStored = await chrome.storage.local.get(['device_name']);
const currentDeviceName: string | undefined = currentDeviceNameStored.device_name as string | undefined;
if (devices.length === 0) {
devicesHtml = `<p class="muted" style="font-size:12px; text-align:center; margin-top:8px;">No devices registered.</p>`;
} else {
devicesHtml = devices.map((d) => {
const isCurrent = d.name === currentDeviceName;
return `
<div class="device-row" style="display:flex; align-items:center; justify-content:space-between; padding:6px 0; border-bottom:1px solid #21262d;">
<div style="flex:1; min-width:0;">
<div style="font-size:12px; font-weight:500; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
${escapeHtml(d.name)}${isCurrent ? ' <span style="color:#8b949e; font-weight:400; font-size:11px;">(this device)</span>' : ''}
</div>
<div style="font-size:11px; color:#8b949e;">added ${relativeTime(d.added_at)}</div>
</div>
${isCurrent ? '' : `
<button
class="btn sec-revoke-btn"
data-device-name="${escapeHtml(d.name)}"
style="font-size:11px; margin-left:8px; flex-shrink:0;"
>revoke</button>
`}
</div>
`;
}).join('');
}
}
// --- Assemble ---
container.innerHTML = `
<div class="settings-section" style="margin-top:0;">
<div class="settings-section__title" style="font-size:12px; color:#8b949e; margin-bottom:8px; text-transform:uppercase; letter-spacing:0.05em;">
Recovery QR
</div>
${qrStatusHtml}
<div id="sec-qr-error" style="font-size:11px; color:#f85149; margin-top:4px; min-height:14px;"></div>
</div>
<div class="settings-section" style="margin-top:16px;">
<div class="settings-section__title" style="font-size:12px; color:#8b949e; margin-bottom:8px; text-transform:uppercase; letter-spacing:0.05em;">
Trusted Devices
</div>
<div id="sec-devices-list">
${devicesHtml}
</div>
</div>
`;
// --- Wire handlers ---
const setQrError = (msg: string): void => {
const el = document.getElementById('sec-qr-error');
if (el) el.textContent = msg;
};
async function doGenerateQr(isRegen: boolean): Promise<void> {
const passphrase = prompt(
isRegen
? 'Enter your vault passphrase to regenerate the recovery QR:'
: 'Enter your vault passphrase to generate the recovery QR:',
);
if (!passphrase) return;
const btn = document.getElementById(isRegen ? 'sec-regenerate-qr' : 'sec-generate-qr') as HTMLButtonElement | null;
if (btn) { btn.disabled = true; btn.textContent = '…'; }
const resp = await sendMessage({ type: 'generate_recovery_qr', passphrase });
if (!resp.ok) {
setQrError(`Failed: ${resp.error}`);
if (btn) { btn.disabled = false; btn.textContent = isRegen ? 'Regenerate…' : 'Generate recovery QR…'; }
return;
}
const svg = (resp.data as { svg: string }).svg;
const now = Math.floor(Date.now() / 1000);
// Store only the timestamp, NEVER the QR payload
await chrome.storage.local.set({ recovery_qr_generated_at: now });
showQrModal(svg);
// Re-render to reflect new state (timestamp now exists)
await renderSecuritySection(container, sessionHandle);
}
document.getElementById('sec-generate-qr')?.addEventListener('click', () => {
void doGenerateQr(false);
});
document.getElementById('sec-regenerate-qr')?.addEventListener('click', () => {
void doGenerateQr(true);
});
document.getElementById('sec-show-qr')?.addEventListener('click', async () => {
const passphrase = prompt('Enter your vault passphrase to view the recovery QR:');
if (!passphrase) return;
const btn = document.getElementById('sec-show-qr') as HTMLButtonElement | null;
if (btn) { btn.disabled = true; btn.textContent = '…'; }
const resp = await sendMessage({ type: 'generate_recovery_qr', passphrase });
if (!resp.ok) {
setQrError(`Failed: ${resp.error}`);
if (btn) { btn.disabled = false; btn.textContent = 'Show / print QR…'; }
return;
}
if (btn) { btn.disabled = false; btn.textContent = 'Show / print QR…'; }
const svg = (resp.data as { svg: string }).svg;
showQrModal(svg);
});
// Revoke buttons
container.querySelectorAll<HTMLButtonElement>('.sec-revoke-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
const name = btn.dataset.deviceName;
if (!name) return;
if (!confirm(`Revoke "${name}"? This device will no longer be authorized.`)) return;
btn.disabled = true;
btn.textContent = '…';
const result = await sendMessage({ type: 'revoke_device', name });
if (result.ok) {
await sendMessage({ type: 'sync' });
// Re-render to refresh device list
await renderSecuritySection(container, sessionHandle);
} else {
btn.disabled = false;
btn.textContent = 'revoke';
setQrError(`Revoke failed: ${result.error}`);
}
});
});
}
export function teardownSecuritySection(): void {
removeModal();
}

View File

@@ -2,7 +2,7 @@
import { sendMessage, navigate, escapeHtml } from '../../shared/state'; import { sendMessage, navigate, escapeHtml } from '../../shared/state';
import type { DeviceSettings } from '../../shared/types'; import type { DeviceSettings } from '../../shared/types';
import { GLYPH_TRASH, GLYPH_DEVICES } from '../../shared/glyphs'; import { GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SYNC } from '../../shared/glyphs';
import { import {
loadColorScheme, saveColorScheme, resetColorScheme, loadColorScheme, saveColorScheme, resetColorScheme,
DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR, DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR,
@@ -63,7 +63,7 @@ export async function renderSettings(app: HTMLElement): Promise<void> {
<div style="margin-bottom:16px;"> <div style="margin-bottom:16px;">
<button class="btn" id="trash-btn" style="width:100%;margin-bottom:8px;">${GLYPH_TRASH} trash</button> <button class="btn" id="trash-btn" style="width:100%;margin-bottom:8px;">${GLYPH_TRASH} trash</button>
<button class="btn" id="devices-btn" style="width:100%;margin-bottom:8px;">${GLYPH_DEVICES} devices</button> <button class="btn" id="devices-btn" style="width:100%;margin-bottom:8px;">${GLYPH_DEVICES} devices</button>
<button class="btn" id="sync-now-btn" style="width:100%;margin-bottom:8px;">📤 Sync now</button> <button class="btn" id="sync-now-btn" style="width:100%;margin-bottom:8px;">${GLYPH_SYNC} Sync now</button>
<div id="sync-status" class="muted" style="font-size:12px;min-height:16px;"></div> <div id="sync-status" class="muted" style="font-size:12px;min-height:16px;"></div>
</div> </div>
@@ -189,3 +189,8 @@ async function renderDisplaySection(): Promise<void> {
updateSwatch(swatch, DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR); updateSwatch(swatch, DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR);
}); });
} }
// DEV-B interface contract stub — will be replaced with real teardown logic at merge time
export function teardownSettings(): void {
// no-op stub
}

View File

@@ -2,10 +2,19 @@
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../shared/state'; import { getState, setState, sendMessage, navigate, escapeHtml } from '../../shared/state';
import type { ItemId, ManifestEntry, VaultSettings } from '../../shared/types'; import type { ItemId, ManifestEntry, VaultSettings } from '../../shared/types';
import {
GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_CARD,
GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT, GLYPH_TYPE_TOTP,
} from '../../shared/glyphs';
const TYPE_ICONS: Record<string, string> = { const TYPE_ICONS: Record<string, string> = {
login: '🔑', secure_note: '📝', identity: '👤', card: '💳', login: GLYPH_TYPE_LOGIN,
key: '🔐', document: '📄', totp: '⏱️', secure_note: GLYPH_TYPE_SECURE_NOTE,
identity: GLYPH_TYPE_IDENTITY,
card: GLYPH_TYPE_CARD,
key: GLYPH_TYPE_KEY,
document: GLYPH_TYPE_DOCUMENT,
totp: GLYPH_TYPE_TOTP,
}; };
function relativeTime(unixSec: number): string { function relativeTime(unixSec: number): string {
@@ -64,7 +73,7 @@ export async function renderTrash(app: HTMLElement): Promise<void> {
? `<p class="muted" style="text-align:center;margin-top:32px;">Trash is empty</p>` ? `<p class="muted" style="text-align:center;margin-top:32px;">Trash is empty</p>`
: items.map(([id, entry]) => ` : items.map(([id, entry]) => `
<div class="trash-row" data-id="${escapeHtml(id)}"> <div class="trash-row" data-id="${escapeHtml(id)}">
<span class="trash-row__icon">${TYPE_ICONS[entry.type] ?? '📦'}</span> <span class="trash-row__icon">${TYPE_ICONS[entry.type] ?? ''}</span>
<div class="trash-row__info"> <div class="trash-row__info">
<span class="trash-row__title">${escapeHtml(entry.title)}</span> <span class="trash-row__title">${escapeHtml(entry.title)}</span>
<span class="trash-row__meta">trashed ${relativeTime(entry.trashed_at ?? 0)}</span> <span class="trash-row__meta">trashed ${relativeTime(entry.trashed_at ?? 0)}</span>

View File

@@ -4,7 +4,7 @@
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../../shared/state'; import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../../shared/state';
import { renderFormHeader } from '../form-header'; import { renderFormHeader } from '../form-header';
import { REQUIRED_PILL_HTML } from '../../../shared/glyphs'; import { REQUIRED_PILL_HTML, GLYPH_TYPE_DOCUMENT, GLYPH_PREVIEW } from '../../../shared/glyphs';
import type { Item, ItemId, ManifestEntry, Section, AttachmentRef } from '../../../shared/types'; import type { Item, ItemId, ManifestEntry, Section, AttachmentRef } from '../../../shared/types';
import { import {
renderSectionsEditor, wireSectionsEditor, renderSectionsEditor, wireSectionsEditor,
@@ -76,7 +76,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
} }
return ` return `
<div class="document-primary-row" id="primary-picker"> <div class="document-primary-row" id="primary-picker">
<span class="document-primary-row__thumb">📄</span> <span class="document-primary-row__thumb">${GLYPH_TYPE_DOCUMENT}</span>
<span class="document-primary-row__name">${escapeHtml(primaryRef.filename)}</span> <span class="document-primary-row__name">${escapeHtml(primaryRef.filename)}</span>
<span class="document-primary-row__meta">${formatBytes(primaryRef.size)}</span> <span class="document-primary-row__meta">${formatBytes(primaryRef.size)}</span>
<span class="document-primary-row__action">↑ change</span> <span class="document-primary-row__action">↑ change</span>
@@ -283,13 +283,13 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise<void>
<div class="detail-title" style="margin-bottom:12px;">${escapeHtml(item.title)}</div> <div class="detail-title" style="margin-bottom:12px;">${escapeHtml(item.title)}</div>
<div class="document-signature-block" id="doc-sigblock"> <div class="document-signature-block" id="doc-sigblock">
<div class="document-signature-block__thumb" data-att-id="${escapeHtml(primaryRef.id)}" data-mime="${escapeHtml(primaryRef.mime_type)}">📄</div> <div class="document-signature-block__thumb" data-att-id="${escapeHtml(primaryRef.id)}" data-mime="${escapeHtml(primaryRef.mime_type)}">${GLYPH_TYPE_DOCUMENT}</div>
<div class="document-signature-block__info"> <div class="document-signature-block__info">
<div class="document-signature-block__name">${escapeHtml(primaryRef.filename)}</div> <div class="document-signature-block__name">${escapeHtml(primaryRef.filename)}</div>
<div class="document-signature-block__meta">${formatBytes(primaryRef.size)} · ${new Date(primaryRef.created * 1000).toISOString().slice(0, 10)}</div> <div class="document-signature-block__meta">${formatBytes(primaryRef.size)} · ${new Date(primaryRef.created * 1000).toISOString().slice(0, 10)}</div>
<div class="document-signature-block__actions"> <div class="document-signature-block__actions">
<span id="doc-download" style="cursor:pointer;color:#d2ab43;">↓ download</span> <span id="doc-download" style="cursor:pointer;color:#d2ab43;">↓ download</span>
${isImageMime ? '<span id="doc-preview" style="cursor:pointer;color:#d2ab43;margin-left:10px;">🔍 preview</span>' : ''} ${isImageMime ? `<span id="doc-preview" style="cursor:pointer;color:#d2ab43;margin-left:10px;">${GLYPH_PREVIEW} preview</span>` : ''}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -424,6 +424,41 @@ textarea {
background: #aa812a; background: #aa812a;
} }
/* Setup wizard — Style C progress track */
.setup-progress-track {
display: flex;
gap: 4px;
width: 100%;
max-width: 560px;
margin: 8px auto 16px;
}
.setup-progress-segment {
flex: 1;
height: 4px;
border-radius: 2px;
}
.setup-progress-segment--completed { background: var(--success, #238636); }
.setup-progress-segment--active { background: var(--gold, #b8860b); }
.setup-progress-segment--pending { background: var(--border, #30363d); }
/* Setup wizard — Recovery QR banner */
.recovery-qr-banner {
padding: 12px 16px;
background: var(--bg-elevated, #161b22);
border: 1px solid var(--gold, #b8860b);
border-radius: 8px;
}
.recovery-qr-banner__header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.recovery-qr-banner__actions {
display: flex;
gap: 8px;
}
/* Spinner */ /* Spinner */
.spinner { .spinner {
display: inline-block; display: inline-block;
@@ -1573,3 +1608,94 @@ textarea {
margin-top: 8px; margin-top: 8px;
background: var(--bg-input); background: var(--bg-input);
} }
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
text-align: center;
}
.empty-state__icon {
font-size: 28px;
color: var(--text-muted, #8b949e);
margin-bottom: 12px;
display: block;
}
.empty-state__title {
font-size: 13px;
font-weight: 600;
margin-bottom: 4px;
}
.empty-state__hint {
font-size: 11px;
color: var(--text-muted, #8b949e);
}
.type-card-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 12px;
}
.type-card {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 10px 12px;
background: var(--bg-elevated, #161b22);
border: 1px solid var(--border-mid, #30363d);
border-radius: 6px;
cursor: pointer;
text-align: left;
transition: border-color 0.15s;
}
.type-card:hover { border-color: var(--gold-base, #a88a4a); }
.type-card__icon { font-size: 20px; margin-bottom: 4px; }
.type-card__label { font-size: 12px; font-weight: 600; }
/* Toast notifications */
.relicario-toast-container {
position: fixed;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 6px;
pointer-events: none;
z-index: 9999;
}
.vault-shell .relicario-toast-container {
left: auto;
right: 24px;
transform: none;
}
.relicario-toast {
padding: 8px 16px;
border-radius: 6px;
font-size: 12px;
opacity: 0;
transform: translateY(8px);
transition: opacity 0.2s, transform 0.2s;
pointer-events: none;
}
.relicario-toast--visible {
opacity: 1;
transform: translateY(0);
}
.relicario-toast--success { background: #1f4a24; color: #aff0b5; border: 1px solid #238636; }
.relicario-toast--error { background: #4a1f1f; color: #f0afaf; border: 1px solid #ab2b20; }
.relicario-toast--info { background: #1f2d4a; color: #afc8f0; border: 1px solid #1f6feb; }
.type-card__desc { font-size: 10px; color: var(--text-muted, #8b949e); margin-top: 2px; }

View File

@@ -574,6 +574,26 @@ export async function handle(
} }
} }
case 'generate_recovery_qr': {
const handle = session.getCurrent();
if (!handle) return { ok: false, error: 'vault_locked' };
try {
const svg: string = state.wasm.wasm_generate_recovery_qr(handle, msg.passphrase);
return { ok: true, data: { svg } };
} catch (e) {
return { ok: false, error: (e as Error).message };
}
}
case 'unwrap_recovery_qr': {
try {
const imageSecretBytes: Uint8Array = state.wasm.wasm_unwrap_recovery_qr(msg.payload_b64, msg.passphrase);
return { ok: true, data: { image_secret: Array.from(imageSecretBytes) } };
} catch (e) {
return { ok: false, error: (e as Error).message };
}
}
case 'import_lastpass_commit': { case 'import_lastpass_commit': {
const handle = session.getCurrent(); const handle = session.getCurrent();
if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' }; if (!handle || !state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' };

View File

@@ -93,6 +93,17 @@ const state: WizardState = {
deviceName: '', deviceName: '',
}; };
// --- Progress track ---
const SETUP_STEP_NAMES = ['mode', 'host', 'connection', 'vault', 'device', 'done'];
function renderProgressTrack(current: number): string {
return `<div class="setup-progress-track">${SETUP_STEP_NAMES.map((_, i) => {
const cls = i < current ? 'completed' : i === current ? 'active' : 'pending';
return `<div class="setup-progress-segment setup-progress-segment--${cls}" title="${SETUP_STEP_NAMES[i]}"></div>`;
}).join('')}</div>`;
}
// --- State-coupled helpers (pure helpers live in ./setup-helpers.ts) --- // --- State-coupled helpers (pure helpers live in ./setup-helpers.ts) ---
/// Update just the meter DOM without a full re-render (so the input keeps /// Update just the meter DOM without a full re-render (so the input keeps
@@ -168,16 +179,7 @@ function render(): void {
const app = document.getElementById('app'); const app = document.getElementById('app');
if (!app) return; if (!app) return;
const progressHtml = ` const progressHtml = renderProgressTrack(state.step);
<div class="progress-bar">
<div class="step ${state.step > 0 ? 'done' : state.step === 0 ? 'current' : ''}"></div>
<div class="step ${state.step > 1 ? 'done' : state.step === 1 ? 'current' : ''}"></div>
<div class="step ${state.step > 2 ? 'done' : state.step === 2 ? 'current' : ''}"></div>
<div class="step ${state.step > 3 ? 'done' : state.step === 3 ? 'current' : ''}"></div>
<div class="step ${state.step > 4 ? 'done' : state.step === 4 ? 'current' : ''}"></div>
<div class="step ${state.step > 5 ? 'done' : state.step === 5 ? 'current' : ''}"></div>
</div>
`;
let stepHtml = ''; let stepHtml = '';
switch (state.step) { switch (state.step) {
@@ -224,6 +226,7 @@ function renderStep0(): string {
</p> </p>
<div class="mode-cards"> <div class="mode-cards">
<button class="mode-card glass ${isNew ? 'active' : ''}" data-mode="new"> <button class="mode-card glass ${isNew ? 'active' : ''}" data-mode="new">
<span class="mode-card__icon" style="font-size:28px;">◈</span>
<div class="mode-card-title">create new vault</div> <div class="mode-card-title">create new vault</div>
<p class="mode-card-blurb"> <p class="mode-card-blurb">
I'm setting up Relicario for the first time. This will create a fresh I'm setting up Relicario for the first time. This will create a fresh
@@ -231,6 +234,7 @@ function renderStep0(): string {
</p> </p>
</button> </button>
<button class="mode-card glass ${isAttach ? 'active' : ''}" data-mode="attach"> <button class="mode-card glass ${isAttach ? 'active' : ''}" data-mode="attach">
<span class="mode-card__icon" style="font-size:28px;">⌥</span>
<div class="mode-card-title">attach this device</div> <div class="mode-card-title">attach this device</div>
<p class="mode-card-blurb"> <p class="mode-card-blurb">
I already have a vault on another device. Connect this browser to it I already have a vault on another device. Connect this browser to it
@@ -981,6 +985,22 @@ function renderStep5(): string {
const configJson = JSON.stringify(config, null, 2); const configJson = JSON.stringify(config, null, 2);
const isAttach = state.mode === 'attach'; const isAttach = state.mode === 'attach';
const qrBannerHtml = (!isAttach && state.verifiedHandle !== null) ? `
<div class="recovery-qr-banner" id="recovery-qr-banner" style="margin-bottom:16px;">
<div class="recovery-qr-banner__header">
<span style="font-size:20px;">◫</span>
<strong>Generate a recovery QR before you go</strong>
</div>
<p class="muted" style="font-size:12px;margin:4px 0 8px;">
If you lose your reference image, this QR lets you recover your vault. Print it and store it safely.
</p>
<div class="recovery-qr-banner__actions">
<button class="btn btn-primary" id="setup-gen-qr">Generate now</button>
<button class="btn" id="setup-skip-qr">Skip — I'll do this in Settings</button>
</div>
</div>
` : '';
return ` return `
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;"> <div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
<div class="success-box"> <div class="success-box">
@@ -992,6 +1012,8 @@ function renderStep5(): string {
</p> </p>
</div> </div>
${qrBannerHtml}
${isAttach ? '' : ` ${isAttach ? '' : `
<div class="form-group"> <div class="form-group">
<label class="label">reference image</label> <label class="label">reference image</label>
@@ -1026,6 +1048,48 @@ function renderStep5(): string {
} }
function attachStep5(): void { function attachStep5(): void {
document.getElementById('setup-gen-qr')?.addEventListener('click', async () => {
if (!state.verifiedHandle) return;
const btn = document.getElementById('setup-gen-qr') as HTMLButtonElement | null;
if (btn) { btn.disabled = true; btn.textContent = 'Generating…'; }
try {
const { sendMessage } = await import('../shared/state');
const resp = await sendMessage({
type: 'generate_recovery_qr',
sessionHandle: state.verifiedHandle.value,
passphrase: state.passphrase,
} as any) as any;
if (!resp.ok || !resp.data) throw new Error(resp.error ?? 'unknown error');
const svg = (resp.data as { svg: string }).svg;
await new Promise<void>((resolve) => {
chrome.storage.local.set({ recovery_qr_generated_at: Date.now() }, resolve);
});
const banner = document.getElementById('recovery-qr-banner');
if (banner) {
banner.innerHTML = `
<div style="text-align:center;">${svg}</div>
<p style="font-size:12px;color:var(--success,#238636);margin:8px 0 0;">
◉ Recovery QR generated — save or print this now.
</p>
<div style="margin-top:8px;">
<button class="btn" id="setup-qr-done">Done</button>
</div>
`;
document.getElementById('setup-qr-done')?.addEventListener('click', () => {
banner.style.display = 'none';
});
}
} catch (err) {
if (btn) { btn.disabled = false; btn.textContent = 'Generate now'; }
alert(`Failed to generate QR: ${err instanceof Error ? err.message : String(err)}`);
}
});
document.getElementById('setup-skip-qr')?.addEventListener('click', () => {
const banner = document.getElementById('recovery-qr-banner');
if (banner) banner.style.display = 'none';
});
document.getElementById('download-ref-btn')?.addEventListener('click', () => { document.getElementById('download-ref-btn')?.addEventListener('click', () => {
if (!state.referenceImageBytes) return; if (!state.referenceImageBytes) return;
const blob = new Blob([state.referenceImageBytes.buffer as ArrayBuffer], { type: 'image/jpeg' }); const blob = new Blob([state.referenceImageBytes.buffer as ArrayBuffer], { type: 'image/jpeg' });

View File

@@ -41,3 +41,30 @@ describe('glyph constants', () => {
expect(GLYPH_NEXT).toBe('▸'); expect(GLYPH_NEXT).toBe('▸');
}); });
}); });
describe('Stream A glyphs (vault tab + type icons)', () => {
it('exports GLYPH_VAULT_TAB as U+29C9', () => {
expect(glyphs.GLYPH_VAULT_TAB).toBe('⧉');
});
it('exports per-type glyph constants', () => {
expect(glyphs.GLYPH_TYPE_LOGIN).toBe('◉');
expect(glyphs.GLYPH_TYPE_SECURE_NOTE).toBe('◫');
expect(glyphs.GLYPH_TYPE_TOTP).toBe('⊡');
expect(glyphs.GLYPH_TYPE_CARD).toBe('▭');
expect(glyphs.GLYPH_TYPE_IDENTITY).toBe('⌬');
expect(glyphs.GLYPH_TYPE_KEY).toBe('⊹');
expect(glyphs.GLYPH_TYPE_DOCUMENT).toBe('≡');
});
it('per-type glyphs are single codepoints (no emoji)', () => {
const typeGlyphs = [
glyphs.GLYPH_TYPE_LOGIN, glyphs.GLYPH_TYPE_SECURE_NOTE, glyphs.GLYPH_TYPE_TOTP,
glyphs.GLYPH_TYPE_CARD, glyphs.GLYPH_TYPE_IDENTITY, glyphs.GLYPH_TYPE_KEY,
glyphs.GLYPH_TYPE_DOCUMENT,
];
for (const g of typeGlyphs) {
expect([...g].length).toBe(1);
}
});
});

View File

@@ -17,6 +17,19 @@ export const GLYPH_DEVICES = '⌬'; // sidebar devices nav
export const GLYPH_SETTINGS = '⚙'; // sidebar settings nav export const GLYPH_SETTINGS = '⚙'; // sidebar settings nav
export const GLYPH_LOCK = '⏻'; // sidebar lock nav export const GLYPH_LOCK = '⏻'; // sidebar lock nav
export const GLYPH_NEXT = '▸'; // forward / next button (matches ▾/▸ disclosure family) export const GLYPH_NEXT = '▸'; // forward / next button (matches ▾/▸ disclosure family)
export const GLYPH_COPY = '⎘'; // copy to clipboard
export const GLYPH_SYNC = '⇅'; // sync / upload
export const GLYPH_PREVIEW = '⊕'; // preview / expand
export const GLYPH_VAULT_TAB = '⧉'; // U+29C9 pop-out to fullscreen vault tab
export const GLYPH_TYPE_LOGIN = '◉'; // login
export const GLYPH_TYPE_SECURE_NOTE = '◫'; // secure note
export const GLYPH_TYPE_TOTP = '⊡'; // totp / 2FA
export const GLYPH_TYPE_CARD = '▭'; // card
export const GLYPH_TYPE_IDENTITY = '⌬'; // identity
export const GLYPH_TYPE_KEY = '⊹'; // SSH / API key
export const GLYPH_TYPE_DOCUMENT = '≡'; // document
/// Inline HTML snippet for the required-field pill. Use after a label's text: /// Inline HTML snippet for the required-field pill. Use after a label's text:
/// `<label class="label" for="f-title">title ${REQUIRED_PILL_HTML}</label>` /// `<label class="label" for="f-title">title ${REQUIRED_PILL_HTML}</label>`

View File

@@ -61,7 +61,9 @@ export type PopupMessage =
} }
| { type: 'parse_lastpass_csv'; bytes: ArrayBuffer } | { 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 }; | { type: 'preview_totp_from_secret'; secret_b32: string }
| { type: 'generate_recovery_qr'; passphrase: string }
| { type: 'unwrap_recovery_qr'; payload_b64: string; passphrase: string };
// --- Messages a content script may send --- // --- Messages a content script may send ---
@@ -173,6 +175,7 @@ export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = new Set([
'export_backup', 'restore_backup', 'export_backup', 'restore_backup',
'parse_lastpass_csv', 'import_lastpass_commit', 'parse_lastpass_csv', 'import_lastpass_commit',
'preview_totp_from_secret', 'preview_totp_from_secret',
'generate_recovery_qr', 'unwrap_recovery_qr',
] as PopupMessage['type'][]); ] as PopupMessage['type'][]);
export interface ExportBackupResponse extends Extract<Response, { ok: true }> { export interface ExportBackupResponse extends Extract<Response, { ok: true }> {

View File

@@ -0,0 +1,26 @@
export function showToast(
message: string,
type: 'success' | 'error' | 'info' = 'info',
durationMs = 2500,
): void {
let container = document.querySelector<HTMLElement>('.relicario-toast-container');
if (!container) {
container = document.createElement('div');
container.className = 'relicario-toast-container';
document.body.appendChild(container);
}
const toast = document.createElement('div');
toast.className = `relicario-toast relicario-toast--${type}`;
toast.textContent = message;
container.appendChild(toast);
requestAnimationFrame(() => {
requestAnimationFrame(() => toast.classList.add('relicario-toast--visible'));
});
setTimeout(() => {
toast.classList.remove('relicario-toast--visible');
toast.addEventListener('transitionend', () => toast.remove(), { once: true });
}, durationMs);
}

View File

@@ -1396,6 +1396,281 @@ textarea {
color: #484f58; color: #484f58;
} }
/* === 3-column shell === */
.vault-shell {
display: flex;
height: 100vh;
overflow: hidden;
background: var(--bg-page, #0d1117);
}
.vault-sidebar {
width: 200px;
min-width: 200px;
display: flex;
flex-direction: column;
border-right: 1px solid var(--border, #30363d);
background: var(--bg-sidebar, #0d1117);
overflow-y: auto;
flex-shrink: 0;
}
.vault-list-pane {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
min-width: 0;
}
.vault-drawer {
width: 440px;
min-width: 440px;
max-width: 440px;
border-left: 1px solid var(--border, #30363d);
background: var(--bg-elevated, #161b22);
overflow-y: auto;
transform: translateX(100%);
transition: transform 0.2s ease;
flex-shrink: 0;
}
.vault-drawer--open {
transform: translateX(0);
}
.vault-list-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
cursor: pointer;
border-bottom: 1px solid var(--border-subtle, #21262d);
transition: background 0.1s;
}
.vault-list-row:hover { background: var(--bg-hover, #161b22); }
.vault-list-row--selected {
background: var(--bg-selected, #1c2d41);
border-left: 2px solid var(--gold, #b8860b);
}
.vault-list-row__icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-elevated, #161b22);
border-radius: 6px;
border: 1px solid var(--border, #30363d);
font-size: 14px;
flex-shrink: 0;
}
.vault-list-row--selected .vault-list-row__icon { border-color: var(--gold, #b8860b); }
.vault-list-row__text { flex: 1; min-width: 0; }
.vault-list-row__title {
font-size: 13px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vault-list-row__subtitle {
font-size: 11px;
color: var(--text-muted, #8b949e);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-top: 1px;
}
.vault-list-row__age {
font-size: 10px;
color: var(--text-dim, #6e7681);
flex-shrink: 0;
}
/* Bottom sheet */
.vault-bottom-sheet-scrim {
position: absolute;
inset: 0 0 0 200px;
background: rgba(0,0,0,0.5);
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
z-index: 100;
}
.vault-bottom-sheet-scrim--visible {
opacity: 1;
pointer-events: auto;
}
.vault-bottom-sheet {
position: absolute;
bottom: 0;
left: 200px;
right: 0;
background: var(--bg-elevated, #161b22);
border-top: 1px solid var(--border, #30363d);
border-radius: 12px 12px 0 0;
padding: 16px 24px 24px;
transform: translateY(100%);
transition: transform 0.25s ease;
z-index: 101;
max-height: 60vh;
overflow-y: auto;
}
.vault-bottom-sheet--open { transform: translateY(0); }
.vault-bottom-sheet__handle {
width: 40px;
height: 4px;
background: var(--border, #30363d);
border-radius: 2px;
margin: 0 auto 16px;
}
.vault-bottom-sheet__title {
font-size: 14px;
font-weight: 600;
margin-bottom: 16px;
text-align: center;
}
.vault-type-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 10px;
}
.vault-type-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 12px 8px;
background: var(--bg-page, #0d1117);
border: 1px solid var(--border, #30363d);
border-radius: 8px;
cursor: pointer;
transition: border-color 0.15s;
gap: 6px;
}
.vault-type-card:hover { border-color: var(--gold, #b8860b); }
.vault-type-card__icon { font-size: 28px; }
.vault-type-card__name { font-size: 11px; color: var(--text-muted, #8b949e); }
/* Drawer header and body */
.vault-drawer__header {
display: flex;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--border, #30363d);
gap: 8px;
}
.vault-drawer__type-pill {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 2px 8px;
background: var(--bg-page, #0d1117);
border: 1px solid var(--border, #30363d);
border-radius: 4px;
color: var(--text-muted, #8b949e);
}
.vault-drawer__actions { display: flex; gap: 6px; margin-left: auto; }
.vault-drawer__close {
background: transparent;
border: none;
cursor: pointer;
font-size: 16px;
color: var(--text-muted, #8b949e);
padding: 4px 6px;
}
.vault-drawer__body { padding: 20px 20px 16px; }
.vault-drawer__title { font-size: 18px; font-weight: 700; margin-bottom: 4px; }
.vault-drawer__subtitle { font-size: 12px; color: var(--text-muted, #8b949e); margin-bottom: 16px; }
.vault-drawer__field-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.vault-drawer__field-grid > .vault-drawer__field--full { grid-column: 1 / -1; }
.vault-drawer__field-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted, #8b949e);
margin-bottom: 2px;
}
.vault-drawer__field-value {
font-size: 13px;
word-break: break-all;
}
/* Category nav */
.vault-category-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 12px;
background: transparent;
border: none;
cursor: pointer;
color: inherit;
font-size: 13px;
text-align: left;
}
.vault-category-row:hover { background: var(--bg-hover, #161b22); }
.vault-category-row--active { background: var(--bg-selected, #1c2d41); }
.vault-category-row__icon { font-size: 14px; flex-shrink: 0; }
.vault-category-row__label { flex: 1; }
.vault-category-row__count { font-size: 11px; color: var(--text-muted, #8b949e); }
/* === Responsive === */
@media (max-width: 960px) {
.vault-drawer {
position: absolute;
right: 0;
top: 0;
height: 100%;
}
}
@media (max-width: 720px) {
.vault-sidebar {
width: 48px;
min-width: 48px;
}
.vault-sidebar__category-label,
.vault-sidebar__category-count,
.vault-sidebar__nav-label {
display: none;
}
.vault-sidebar__nav-item { justify-content: center; padding: 10px 0; }
}
/* --- Lock screen (vault tab) --- */ /* --- Lock screen (vault tab) --- */
.vault-lock-screen { .vault-lock-screen {
@@ -1719,3 +1994,41 @@ textarea {
background: linear-gradient(to top, rgba(17, 22, 30, 0.7), transparent); background: linear-gradient(to top, rgba(17, 22, 30, 0.7), transparent);
pointer-events: none; pointer-events: none;
} }
/* Toast notifications */
.relicario-toast-container {
position: fixed;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 6px;
pointer-events: none;
z-index: 9999;
}
.vault-shell .relicario-toast-container {
left: auto;
right: 24px;
transform: none;
}
.relicario-toast {
padding: 8px 16px;
border-radius: 6px;
font-size: 12px;
opacity: 0;
transform: translateY(8px);
transition: opacity 0.2s, transform 0.2s;
pointer-events: none;
}
.relicario-toast--visible {
opacity: 1;
transform: translateY(0);
}
.relicario-toast--success { background: #1f4a24; color: #aff0b5; border: 1px solid #238636; }
.relicario-toast--error { background: #4a1f1f; color: #f0afaf; border: 1px solid #ab2b20; }
.relicario-toast--info { background: #1f2d4a; color: #afc8f0; border: 1px solid #1f6feb; }

View File

@@ -10,18 +10,36 @@ import type {
} from '../shared/types'; } from '../shared/types';
import { registerHost } from '../shared/state'; import { registerHost } from '../shared/state';
import { lookupErrorCopy, type ErrorCta } from '../shared/error-copy'; import { lookupErrorCopy, type ErrorCta } from '../shared/error-copy';
import { GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_LOCK } from '../shared/glyphs'; import {
GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_LOCK,
GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_TOTP,
GLYPH_TYPE_CARD, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT,
} from '../shared/glyphs';
import { renderItemDetail } from '../popup/components/item-detail'; import { renderItemDetail } from '../popup/components/item-detail';
import { renderItemForm } from '../popup/components/item-form'; import { renderItemForm } from '../popup/components/item-form';
import { renderTrash, teardown as teardownTrash } from '../popup/components/trash'; import { renderTrash, teardown as teardownTrash } from '../popup/components/trash';
import { renderDevices, teardown as teardownDevices } from '../popup/components/devices'; import { renderDevices, teardown as teardownDevices } from '../popup/components/devices';
import { renderSettings } from '../popup/components/settings'; import { renderSettings, teardownSettings } from '../popup/components/settings';
import { renderVaultSettings as renderVaultSettingsView } from '../popup/components/settings-vault'; import { renderVaultSettings as renderVaultSettingsView } from '../popup/components/settings-vault';
import { renderFieldHistory, teardown as teardownFieldHistory } from '../popup/components/field-history'; import { renderFieldHistory, teardown as teardownFieldHistory } from '../popup/components/field-history';
import { renderBackupPanel, teardown as teardownBackup } from './components/backup-panel'; import { renderBackupPanel, teardown as teardownBackup } from './components/backup-panel';
import { renderImportPanel, teardown as teardownImport } from './components/import-panel'; import { renderImportPanel, teardown as teardownImport } from './components/import-panel';
import { applyColorScheme } from '../shared/color-scheme'; import { applyColorScheme } from '../shared/color-scheme';
// ---------------------------------------------------------------------------
// Bottom sheet type picker
// ---------------------------------------------------------------------------
const BOTTOM_SHEET_TYPES: Array<{ type: ItemType; label: string }> = [
{ type: 'login', label: 'Login' },
{ type: 'secure_note', label: 'Secure Note' },
{ type: 'totp', label: 'TOTP' },
{ type: 'card', label: 'Card' },
{ type: 'identity', label: 'Identity' },
{ type: 'key', label: 'SSH / API Key' },
{ type: 'document', label: 'Document' },
];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -60,26 +78,35 @@ function renderErrorBlock(code: string | null | undefined): string {
function typeIcon(t: ItemType): string { function typeIcon(t: ItemType): string {
switch (t) { switch (t) {
case 'login': return '\u{1F511}'; // key case 'login': return GLYPH_TYPE_LOGIN;
case 'secure_note': return '\u{1F4DD}'; // memo case 'secure_note': return GLYPH_TYPE_SECURE_NOTE;
case 'identity': return '\u{1FAAA}'; // id card case 'identity': return GLYPH_TYPE_IDENTITY;
case 'card': return '\u{1F4B3}'; // credit card case 'card': return GLYPH_TYPE_CARD;
case 'key': return '\u{1F5DD}'; // old key case 'key': return GLYPH_TYPE_KEY;
case 'document': return '\u{1F4C4}'; // page facing up case 'document': return GLYPH_TYPE_DOCUMENT;
case 'totp': return '⏱'; // stopwatch case 'totp': return GLYPH_TYPE_TOTP;
} }
} }
function typeLabel(t: ItemType): string { function typeLabel(t: ItemType): string {
switch (t) { const labels: Record<ItemType, string> = {
case 'login': return 'Logins'; login: 'Login',
case 'secure_note': return 'Secure Notes'; secure_note: 'Secure Note',
case 'identity': return 'Identities'; identity: 'Identity',
case 'card': return 'Cards'; card: 'Card',
case 'key': return 'Keys'; key: 'SSH / API Key',
case 'document': return 'Documents'; document: 'Document',
case 'totp': return 'TOTP'; totp: 'TOTP',
} };
return labels[t];
}
function relativeTime(unixSec: number): string {
const diffS = Math.floor(Date.now() / 1000) - unixSec;
if (diffS < 60) return 'just now';
if (diffS < 3600) return `${Math.floor(diffS / 60)}m ago`;
if (diffS < 86400) return `${Math.floor(diffS / 3600)}h ago`;
return `${Math.floor(diffS / 86400)}d ago`;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -138,6 +165,8 @@ interface VaultState {
selectedIndex: number; selectedIndex: number;
searchQuery: string; searchQuery: string;
activeGroup: string | null; activeGroup: string | null;
drawerOpen: boolean;
bottomSheetOpen: boolean;
vaultSettings: VaultSettings | null; vaultSettings: VaultSettings | null;
generatorDefaults: GeneratorRequest | null; generatorDefaults: GeneratorRequest | null;
error: string | null; error: string | null;
@@ -157,6 +186,8 @@ const state: VaultState = {
selectedIndex: 0, selectedIndex: 0,
searchQuery: '', searchQuery: '',
activeGroup: null, activeGroup: null,
drawerOpen: false,
bottomSheetOpen: false,
vaultSettings: null, vaultSettings: null,
generatorDefaults: null, generatorDefaults: null,
error: null, error: null,
@@ -180,7 +211,8 @@ registerHost({
navigate: (view: string, extras?: any) => { navigate: (view: string, extras?: any) => {
Object.assign(state, { view, error: null, loading: false, ...extras }); Object.assign(state, { view, error: null, loading: false, ...extras });
setHash(view as VaultView); setHash(view as VaultView);
renderSidebarList(); renderSidebarCategories();
renderListPane();
renderPane(); renderPane();
}, },
sendMessage, sendMessage,
@@ -249,39 +281,220 @@ function renderLockScreen(app: HTMLElement): void {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Shell (sidebar + pane) // Shell (3-column: sidebar + list pane + drawer)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function renderShell(app: HTMLElement): void { function renderShell(app: HTMLElement): void {
// Only create the shell structure if it's not present yet if (!app.querySelector('.vault-shell')) {
if (!app.querySelector('.vault-sidebar')) {
app.innerHTML = ` app.innerHTML = `
<div class="vault-sidebar"> <div class="vault-shell">
<div class="vault-sidebar__header"> <div class="vault-sidebar">
<img class="brand-logo" src="icons/relicario-logo-16.svg" alt=""> <div class="vault-sidebar__header">
<span class="brand">Relicario</span> <img class="brand-logo" src="icons/relicario-logo-16.svg" alt="">
<span class="brand">Relicario</span>
</div>
<div class="vault-sidebar__search">
<input type="text" id="vault-search" placeholder="/ search…" />
</div>
<nav class="vault-sidebar__categories" id="vault-categories" aria-label="Item types"></nav>
<div class="vault-sidebar__nav">
<button class="vault-sidebar__nav-item" data-nav="add" title="New item">+ new item</button>
<button class="vault-sidebar__nav-item" data-nav="trash" title="Trash">${GLYPH_TRASH} <span class="vault-sidebar__nav-label">trash</span></button>
<button class="vault-sidebar__nav-item" data-nav="devices" title="Devices">${GLYPH_DEVICES} <span class="vault-sidebar__nav-label">devices</span></button>
<button class="vault-sidebar__nav-item" data-nav="settings" title="Settings">${GLYPH_SETTINGS} <span class="vault-sidebar__nav-label">settings</span></button>
<button class="vault-sidebar__nav-item" data-nav="lock" title="Lock">${GLYPH_LOCK} <span class="vault-sidebar__nav-label">lock</span></button>
</div>
</div> </div>
<div class="vault-sidebar__search"> <div class="vault-list-pane" id="vault-list-pane"></div>
<input type="text" id="vault-search" placeholder="/ search..." /> <div class="vault-drawer" id="vault-drawer"></div>
</div> <div class="vault-bottom-sheet-scrim" id="vault-sheet-scrim"></div>
<div class="vault-sidebar__list" id="vault-sidebar-list"></div> <div class="vault-bottom-sheet" id="vault-bottom-sheet"></div>
<div class="vault-sidebar__nav">
<button class="vault-sidebar__nav-item" data-nav="add">+ new item</button>
<button class="vault-sidebar__nav-item" data-nav="trash">${GLYPH_TRASH} trash</button>
<button class="vault-sidebar__nav-item" data-nav="devices">${GLYPH_DEVICES} devices</button>
<button class="vault-sidebar__nav-item" data-nav="settings">${GLYPH_SETTINGS} settings</button>
<button class="vault-sidebar__nav-item" data-nav="lock">${GLYPH_LOCK} lock</button>
</div>
</div>
<div class="vault-pane vault-pane--empty" id="vault-pane">
select an item
</div> </div>
`; `;
wireSidebar(); wireSidebar();
wireBottomSheet();
} }
renderSidebarList(); renderSidebarCategories();
renderPane(); renderListPane();
if (state.drawerOpen && state.selectedItem) {
renderDrawer(state.selectedItem);
}
}
// ---------------------------------------------------------------------------
// Bottom sheet (wired in Task 11)
// ---------------------------------------------------------------------------
function wireBottomSheet(): void {
document.getElementById('vault-sheet-scrim')?.addEventListener('click', closeBottomSheet);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && state.bottomSheetOpen) closeBottomSheet();
});
}
function openBottomSheet(): void {
const sheet = document.getElementById('vault-bottom-sheet');
const scrim = document.getElementById('vault-sheet-scrim');
if (!sheet || !scrim) return;
sheet.innerHTML = `
<div class="vault-bottom-sheet__handle"></div>
<div class="vault-bottom-sheet__title">New item — choose type</div>
<div class="vault-type-grid">
${BOTTOM_SHEET_TYPES.map((t) => `
<button class="vault-type-card" data-type="${t.type}">
<span class="vault-type-card__icon" aria-hidden="true">${typeIcon(t.type)}</span>
<span class="vault-type-card__name">${escapeHtml(t.label)}</span>
</button>
`).join('')}
</div>
`;
sheet.classList.add('vault-bottom-sheet--open');
scrim.classList.add('vault-bottom-sheet-scrim--visible');
state.bottomSheetOpen = true;
sheet.querySelectorAll<HTMLButtonElement>('[data-type]').forEach((btn) => {
btn.addEventListener('click', () => {
const type = btn.dataset.type as ItemType;
closeBottomSheet();
setHash('add', type);
renderPane();
});
});
}
function closeBottomSheet(): void {
document.getElementById('vault-bottom-sheet')?.classList.remove('vault-bottom-sheet--open');
document.getElementById('vault-sheet-scrim')?.classList.remove('vault-bottom-sheet-scrim--visible');
state.bottomSheetOpen = false;
}
// ---------------------------------------------------------------------------
// Drawer (implemented in Task 10)
// ---------------------------------------------------------------------------
function openDrawer(): void {
document.getElementById('vault-drawer')?.classList.add('vault-drawer--open');
}
function closeDrawer(): void {
state.drawerOpen = false;
state.selectedId = null;
state.selectedItem = null;
document.getElementById('vault-drawer')?.classList.remove('vault-drawer--open');
}
function getDrawerCoreFields(item: Item): Array<[string, string, boolean]> {
const core = item.core as unknown as Record<string, unknown>;
if (!core) return [];
const fields: Array<[string, string, boolean]> = [];
switch (item.type) {
case 'login':
if ('username' in core) fields.push(['username', String(core.username ?? ''), false]);
if ('password' in core) fields.push(['password', '••••••••', false]);
if ('url' in core) fields.push(['url', String(core.url ?? ''), true]);
break;
case 'card': {
if ('number' in core) fields.push(['number', String(core.number ?? ''), false]);
if ('holder' in core) fields.push(['holder', String(core.holder ?? ''), false]);
if ('expiry' in core && core.expiry) {
const exp = core.expiry as { month: number; year: number };
fields.push(['expiry', `${String(exp.month).padStart(2, '0')}/${exp.year}`, false]);
}
if ('cvv' in core) fields.push(['cvv', '•••', false]);
if ('pin' in core) fields.push(['pin', '••••', false]);
break;
}
case 'identity':
if ('full_name' in core) fields.push(['full name', String(core.full_name ?? ''), true]);
if ('email' in core) fields.push(['email', String(core.email ?? ''), true]);
if ('phone' in core) fields.push(['phone', String(core.phone ?? ''), false]);
if ('address' in core) fields.push(['address', String(core.address ?? ''), true]);
if ('date_of_birth' in core) fields.push(['date of birth', String(core.date_of_birth ?? ''), false]);
break;
case 'key':
if ('label' in core) fields.push(['label', String(core.label ?? ''), true]);
if ('algorithm' in core) fields.push(['algorithm', String(core.algorithm ?? ''), false]);
if ('public_key' in core) fields.push(['public key', String(core.public_key ?? ''), true]);
break;
case 'secure_note':
if ('body' in core) fields.push(['body', String(core.body ?? ''), true]);
break;
case 'totp':
if ('issuer' in core) fields.push(['issuer', String(core.issuer ?? ''), false]);
if ('label' in core) fields.push(['label', String(core.label ?? ''), false]);
break;
case 'document':
if ('filename' in core) fields.push(['filename', String(core.filename ?? ''), true]);
if ('mime_type' in core) fields.push(['type', String(core.mime_type ?? ''), false]);
break;
}
if (item.notes) fields.push(['notes', item.notes, true]);
return fields;
}
function renderDrawer(item: Item): void {
const drawer = document.getElementById('vault-drawer');
if (!drawer) return;
const coreFields = getDrawerCoreFields(item);
drawer.innerHTML = `
<div class="vault-drawer__header">
<span class="vault-drawer__type-pill">${item.type.replace('_', ' ').toUpperCase()}</span>
<div class="vault-drawer__actions">
<button class="btn" id="drawer-edit-btn" style="font-size:11px;">edit</button>
<button class="vault-drawer__close" id="drawer-close-btn" title="Close (Esc)">✕</button>
</div>
</div>
<div class="vault-drawer__body">
<div class="vault-drawer__title">${escapeHtml(item.title)}</div>
${item.type === 'login' && (item.core as { url?: string }).url
? `<div class="vault-drawer__subtitle">${escapeHtml((item.core as { url?: string }).url ?? '')}</div>`
: ''}
<div class="vault-drawer__field-grid">
${coreFields.map(([label, value, full]) => `
<div class="vault-drawer__field${full ? ' vault-drawer__field--full' : ''}">
<div class="vault-drawer__field-label">${escapeHtml(label)}</div>
<div class="vault-drawer__field-value">${escapeHtml(value)}</div>
</div>
`).join('')}
</div>
</div>
`;
document.getElementById('drawer-close-btn')?.addEventListener('click', () => {
closeDrawer();
renderListPane();
});
document.getElementById('drawer-edit-btn')?.addEventListener('click', () => {
if (state.selectedId) {
setHash('edit', state.selectedId);
renderPane();
}
});
}
// ---------------------------------------------------------------------------
// Item selection (implemented in Task 10)
// ---------------------------------------------------------------------------
async function selectItemForDrawer(id: string): Promise<void> {
const resp = await sendMessage({ type: 'get_item', id });
if (!resp.ok) return;
const data = resp.data as { item: Item };
state.selectedId = id;
state.selectedItem = data.item;
state.drawerOpen = true;
renderSidebarCategories();
renderListPane();
renderDrawer(data.item);
openDrawer();
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -293,7 +506,8 @@ function wireSidebar(): void {
const searchInput = document.getElementById('vault-search') as HTMLInputElement | null; const searchInput = document.getElementById('vault-search') as HTMLInputElement | null;
searchInput?.addEventListener('input', () => { searchInput?.addEventListener('input', () => {
state.searchQuery = searchInput.value; state.searchQuery = searchInput.value;
renderSidebarList(); renderSidebarCategories();
renderListPane();
}); });
// Nav buttons // Nav buttons
@@ -313,8 +527,7 @@ function wireSidebar(): void {
state.selectedId = null; state.selectedId = null;
state.selectedItem = null; state.selectedItem = null;
state.newType = null; state.newType = null;
setHash('add'); openBottomSheet();
renderPane();
return; return;
} }
if (nav === 'trash' || nav === 'devices' || nav === 'settings') { if (nav === 'trash' || nav === 'devices' || nav === 'settings') {
@@ -328,11 +541,16 @@ function wireSidebar(): void {
}); });
}); });
// Global "/" shortcut to focus search // Global "/" shortcut to focus search; Esc to close drawer
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.key === '/' && !isEditableTarget(e.target)) { if (e.key === '/' && !isEditableTarget(e.target)) {
e.preventDefault(); e.preventDefault();
searchInput?.focus(); searchInput?.focus();
return;
}
if (e.key === 'Escape' && state.drawerOpen) {
closeDrawer();
renderListPane();
} }
}); });
} }
@@ -346,7 +564,7 @@ function isEditableTarget(target: EventTarget | null): boolean {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Sidebar list // Sidebar category nav
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function getFilteredEntries(): Array<[ItemId, ManifestEntry]> { function getFilteredEntries(): Array<[ItemId, ManifestEntry]> {
@@ -367,70 +585,96 @@ function getFilteredEntries(): Array<[ItemId, ManifestEntry]> {
return filtered; return filtered;
} }
function renderSidebarList(): void { function renderSidebarCategories(): void {
const container = document.getElementById('vault-sidebar-list'); const container = document.getElementById('vault-categories');
if (!container) return; if (!container) return;
const filtered = getFilteredEntries(); const filtered = getFilteredEntries();
// Group by type
const groups = new Map<ItemType, Array<[ItemId, ManifestEntry]>>();
for (const entry of filtered) {
const t = entry[1].type;
if (!groups.has(t)) groups.set(t, []);
groups.get(t)!.push(entry);
}
if (filtered.length === 0) {
container.innerHTML = '<div class="empty">no items</div>';
return;
}
let html = '';
// Stable type ordering
const typeOrder: ItemType[] = ['login', 'secure_note', 'identity', 'card', 'key', 'document', 'totp']; const typeOrder: ItemType[] = ['login', 'secure_note', 'identity', 'card', 'key', 'document', 'totp'];
const allCount = filtered.length;
const isAllActive = !state.activeGroup && state.view === 'list';
let html = `
<button class="vault-category-row ${isAllActive ? 'vault-category-row--active' : ''}" data-group="">
<span class="vault-category-row__icon">◈</span>
<span class="vault-category-row__label vault-sidebar__category-label">All items</span>
<span class="vault-category-row__count vault-sidebar__category-count">${allCount}</span>
</button>
`;
for (const t of typeOrder) { for (const t of typeOrder) {
const items = groups.get(t); const count = filtered.filter(([, e]) => e.type === t).length;
if (!items || items.length === 0) continue; if (count === 0 && allCount > 0) continue;
html += `<div class="vault-group-header">${typeIcon(t)} ${escapeHtml(typeLabel(t))}</div>`; const isActive = state.activeGroup === t;
for (const [id, e] of items) { html += `
const sel = id === state.selectedId ? ' selected' : ''; <button class="vault-category-row ${isActive ? 'vault-category-row--active' : ''}" data-group="${t}">
const meta = e.icon_hint ? escapeHtml(e.icon_hint) : ''; <span class="vault-category-row__icon">${typeIcon(t)}</span>
html += ` <span class="vault-category-row__label vault-sidebar__category-label">${typeLabel(t)}</span>
<div class="vault-entry${sel}" data-id="${escapeHtml(id)}"> <span class="vault-category-row__count vault-sidebar__category-count">${count}</span>
<span class="vault-entry__title">${escapeHtml(e.title)}</span> </button>
${meta ? `<span class="vault-entry__meta">${meta}</span>` : ''} `;
</div>
`;
}
} }
container.innerHTML = html; container.innerHTML = html;
// Wire clicks container.querySelectorAll<HTMLButtonElement>('.vault-category-row').forEach((btn) => {
container.querySelectorAll('.vault-entry').forEach((el) => { btn.addEventListener('click', () => {
el.addEventListener('click', async () => { state.activeGroup = btn.dataset.group || null;
const id = (el as HTMLElement).dataset.id!; state.drawerOpen = false;
await selectItem(id); state.selectedId = null;
state.selectedItem = null;
renderSidebarCategories();
renderListPane();
closeDrawer();
}); });
}); });
} }
async function selectItem(id: ItemId): Promise<void> { // ---------------------------------------------------------------------------
state.loading = true; // List pane
const resp = await sendMessage({ type: 'get_item', id }); // ---------------------------------------------------------------------------
if (resp.ok) {
const data = resp.data as { item: Item }; function renderListPane(): void {
state.selectedId = id; const pane = document.getElementById('vault-list-pane');
state.selectedItem = data.item; if (!pane) return;
state.loading = false;
setHash('detail', id); const group = state.activeGroup as ItemType | null;
renderSidebarList(); let items = getFilteredEntries();
renderPane(); if (group) items = items.filter(([, e]) => e.type === group);
} else {
state.loading = false; if (items.length === 0) {
state.error = (resp as { error: string }).error; pane.innerHTML = `
<div class="empty-state">
<span class="empty-state__icon" aria-hidden="true">${state.searchQuery ? '⊘' : '◈'}</span>
<div class="empty-state__title">${state.searchQuery ? `No results for "${escapeHtml(state.searchQuery)}"` : 'No items yet'}</div>
<div class="empty-state__hint">${state.searchQuery ? 'Try a shorter search term.' : 'Click + new item to get started.'}</div>
</div>
`;
return;
} }
pane.innerHTML = items.map(([id, e]) => {
const sel = id === state.selectedId ? ' vault-list-row--selected' : '';
const subtitle = (e as any).icon_hint ?? (e.tags?.length > 0 ? e.tags.join(', ') : '');
const modifiedAgo = e.modified ? relativeTime(e.modified) : '';
return `
<div class="vault-list-row${sel}" data-id="${escapeHtml(id)}">
<div class="vault-list-row__icon" aria-hidden="true">${typeIcon(e.type)}</div>
<div class="vault-list-row__text">
<div class="vault-list-row__title">${escapeHtml(e.title)}</div>
${subtitle ? `<div class="vault-list-row__subtitle">${escapeHtml(subtitle)}</div>` : ''}
</div>
${modifiedAgo ? `<div class="vault-list-row__age">${escapeHtml(modifiedAgo)}</div>` : ''}
</div>
`;
}).join('');
pane.querySelectorAll<HTMLElement>('.vault-list-row').forEach((row) => {
row.addEventListener('click', async () => {
await selectItemForDrawer(row.dataset.id!);
});
});
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -446,8 +690,8 @@ const SAVE_HINT = isMac ? '⌘+S to save' : 'Ctrl+S to save';
function renderFormWrapped(app: HTMLElement, mode: 'add' | 'edit'): void { function renderFormWrapped(app: HTMLElement, mode: 'add' | 'edit'): void {
const itemType = state.selectedItem?.type ?? state.newType ?? 'login'; const itemType = state.selectedItem?.type ?? state.newType ?? 'login';
const typeLabel = itemType.replace('_', ' '); const typeLabelText = itemType.replace('_', ' ');
const titleText = mode === 'add' ? `new ${typeLabel}` : `edit ${typeLabel}`; const titleText = mode === 'add' ? `new ${typeLabelText}` : `edit ${typeLabelText}`;
const wrapper = document.createElement('div'); const wrapper = document.createElement('div');
wrapper.className = 'form-pane'; wrapper.className = 'form-pane';
wrapper.innerHTML = ` wrapper.innerHTML = `
@@ -505,6 +749,7 @@ export const __test__ = { renderFormWrapped };
function teardownPaneComponents(): void { function teardownPaneComponents(): void {
teardownTrash(); teardownTrash();
teardownDevices(); teardownDevices();
teardownSettings();
teardownFieldHistory(); teardownFieldHistory();
teardownBackup(); teardownBackup();
teardownImport(); teardownImport();
@@ -554,7 +799,7 @@ function renderPane(): void {
renderDevices(pane); renderDevices(pane);
break; break;
case 'settings': case 'settings':
renderSettings(pane); void renderSettings(pane);
break; break;
case 'settings-vault': case 'settings-vault':
renderVaultSettingsView(pane); renderVaultSettingsView(pane);
@@ -674,7 +919,8 @@ document.addEventListener('DOMContentLoaded', async () => {
if ((route.view === 'detail' || route.view === 'edit') && route.id) { if ((route.view === 'detail' || route.view === 'edit') && route.id) {
if (state.selectedId === route.id && state.selectedItem) { if (state.selectedId === route.id && state.selectedItem) {
renderPane(); renderPane();
renderSidebarList(); renderSidebarCategories();
renderListPane();
return; return;
} }
// Need to fetch the item // Need to fetch the item
@@ -685,7 +931,30 @@ document.addEventListener('DOMContentLoaded', async () => {
// For non-item views, just re-render the pane // For non-item views, just re-render the pane
state.selectedId = null; state.selectedId = null;
state.selectedItem = null; state.selectedItem = null;
renderSidebarList(); renderSidebarCategories();
renderListPane();
renderPane(); renderPane();
}); });
}); });
// ---------------------------------------------------------------------------
// Legacy selectItem — used by hash-change deep linking
// ---------------------------------------------------------------------------
async function selectItem(id: ItemId): Promise<void> {
state.loading = true;
const resp = await sendMessage({ type: 'get_item', id });
if (resp.ok) {
const data = resp.data as { item: Item };
state.selectedId = id;
state.selectedItem = data.item;
state.loading = false;
setHash('detail', id);
renderSidebarCategories();
renderListPane();
renderPane();
} else {
state.loading = false;
state.error = (resp as { error: string }).error;
}
}

View File

@@ -79,6 +79,9 @@ declare module 'relicario-wasm' {
export function clear_device(): void; export function clear_device(): void;
export function get_field_history(item_json: string): unknown; export function get_field_history(item_json: string): unknown;
export function wasm_generate_recovery_qr(handle: SessionHandle, passphrase: string): string;
export function wasm_unwrap_recovery_qr(payload_b64: string, passphrase: string): Uint8Array;
export default function init(module_or_path?: unknown): Promise<void>; export default function init(module_or_path?: unknown): Promise<void>;
export function initSync(args: { module: WebAssembly.Module }): void; export function initSync(args: { module: WebAssembly.Module }): void;
} }