Merge latest origin/main

Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
This commit is contained in:
npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta
2026-07-30 21:02:06 -04:00
48 changed files with 4735 additions and 741 deletions
@@ -12,83 +12,116 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const OUTPUT = path.resolve(
HERE,
"../../src/shared/ui/assets/card-texture.png",
);
// CSS-pixel source geometry. Screenshotting at DPR 2 produces a crisp asset.
const CARD_SIZE = 640;
const OUTSET = 96;
const CAPTURE_SIZE = CARD_SIZE + OUTSET * 2;
const OUTPUT_DIRECTORY = path.resolve(HERE, "../../src/shared/ui/assets");
const DPR = 2;
// Approved texture parameters, archived from the former runtime SVG filter.
const BLUR = 66;
const DILATE = Math.round(BLUR * 0.85);
const THRESHOLD_BIAS = 0.302;
const SLOPE = 8;
const FREQUENCY = 0.999;
const OCTAVES = 3;
const SEED = 5315;
await mkdir(path.dirname(OUTPUT), { recursive: true });
const TEXTURES = [
{
filename: "card-texture.png",
color: "white",
cardSize: 640,
outset: 96,
blur: 66,
innerBand: 112,
},
{
filename: "card-texture-dark.png",
color: "#171b21",
cardSize: 640,
outset: 96,
blur: 66,
innerBand: 112,
},
{
filename: "card-texture-compact.png",
color: "white",
cardSize: 320,
outset: 24,
blur: 24,
innerBand: 44,
},
{
filename: "card-texture-dark-compact.png",
color: "#171b21",
cardSize: 320,
outset: 24,
blur: 24,
innerBand: 44,
},
];
await mkdir(OUTPUT_DIRECTORY, { recursive: true });
const browser = await chromium.launch();
try {
const page = await browser.newPage({
deviceScaleFactor: DPR,
viewport: { height: CAPTURE_SIZE, width: CAPTURE_SIZE },
});
for (const texture of TEXTURES) {
const captureSize = texture.cardSize + texture.outset * 2;
const dilate = Math.round(texture.blur * 0.85);
const output = path.join(OUTPUT_DIRECTORY, texture.filename);
const page = await browser.newPage({
deviceScaleFactor: DPR,
viewport: { height: captureSize, width: captureSize },
});
await page.setContent(`<!doctype html>
<style>
html, body { margin: 0; width: 100%; height: 100%; background: transparent; }
#stage { position: relative; width: ${CAPTURE_SIZE}px; height: ${CAPTURE_SIZE}px; }
#core {
position: absolute;
inset: ${OUTSET + BLUR / 2}px;
background: white;
filter: blur(${BLUR / 3}px);
}
</style>
<div id="stage">
<svg width="${CAPTURE_SIZE}" height="${CAPTURE_SIZE}" aria-hidden="true">
<defs>
<filter id="texture" x="0" y="0" width="100%" height="100%"
filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feMorphology in="SourceAlpha" operator="dilate" radius="${DILATE}" result="squared" />
<feGaussianBlur in="squared" stdDeviation="${BLUR}" result="ramp" />
<feTurbulence type="fractalNoise" baseFrequency="${FREQUENCY}"
numOctaves="${OCTAVES}" seed="${SEED}" result="grain" />
<feColorMatrix in="grain" result="grainAlpha" type="matrix"
values="0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
1 0 0 0 0" />
<feComposite in="ramp" in2="grainAlpha" operator="arithmetic"
k1="0" k2="1" k3="-1" k4="${-THRESHOLD_BIAS}" result="dithered" />
<feComponentTransfer in="dithered" result="specks">
<feFuncA type="linear" slope="${SLOPE}" intercept="0" />
</feComponentTransfer>
<feFlood flood-color="white" result="white" />
<feComposite in="white" in2="specks" operator="in" />
</filter>
</defs>
<rect x="${OUTSET}" y="${OUTSET}" width="${CARD_SIZE}" height="${CARD_SIZE}"
fill="white" filter="url(#texture)" />
</svg>
<span id="core"></span>
</div>`);
await page.setContent(`<!doctype html>
<style>
html, body { margin: 0; width: 100%; height: 100%; background: transparent; }
#stage { position: relative; width: ${captureSize}px; height: ${captureSize}px; }
#core {
position: absolute;
inset: ${texture.outset + texture.blur / 2}px;
background: ${texture.color};
filter: blur(${texture.blur / 3}px);
}
</style>
<div id="stage">
<svg width="${captureSize}" height="${captureSize}" aria-hidden="true">
<defs>
<filter id="texture" x="0" y="0" width="100%" height="100%"
filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feMorphology in="SourceAlpha" operator="dilate" radius="${dilate}" result="squared" />
<feGaussianBlur in="squared" stdDeviation="${texture.blur}" result="ramp" />
<feTurbulence type="fractalNoise" baseFrequency="${FREQUENCY}"
numOctaves="${OCTAVES}" seed="${SEED}" result="grain" />
<feColorMatrix in="grain" result="grainAlpha" type="matrix"
values="0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
1 0 0 0 0" />
<feComposite in="ramp" in2="grainAlpha" operator="arithmetic"
k1="0" k2="1" k3="-1" k4="${-THRESHOLD_BIAS}" result="dithered" />
<feComponentTransfer in="dithered" result="specks">
<feFuncA type="linear" slope="${SLOPE}" intercept="0" />
</feComponentTransfer>
<feFlood flood-color="${texture.color}" result="surfaceColor" />
<feComposite in="surfaceColor" in2="specks" operator="in" />
</filter>
</defs>
<rect x="${texture.outset}" y="${texture.outset}" width="${texture.cardSize}" height="${texture.cardSize}"
fill="${texture.color}" filter="url(#texture)" />
</svg>
<span id="core"></span>
</div>`);
await page.locator("#stage").screenshot({
omitBackground: true,
path: OUTPUT,
});
await page.locator("#stage").screenshot({
omitBackground: true,
path: output,
});
await page.close();
console.log(`Generated ${output}`);
console.log(`Asset: ${captureSize * DPR}×${captureSize * DPR}px @${DPR}x`);
console.log(
`Runtime slice: ${(texture.outset + texture.innerBand) * DPR}px; outset: ${texture.outset}px`,
);
}
} finally {
await browser.close();
}
console.log(`Generated ${OUTPUT}`);
console.log(`Asset: ${CAPTURE_SIZE * DPR}×${CAPTURE_SIZE * DPR}px @${DPR}x`);
console.log(`Runtime slice: ${(OUTSET + 112) * DPR}px; outset: ${OUTSET}px`);
+47 -50
View File
@@ -2,7 +2,7 @@ use std::{
collections::HashMap,
io::Write,
sync::{
atomic::{AtomicBool, AtomicU16},
atomic::{AtomicBool, AtomicU16, AtomicU8},
Arc, Mutex,
},
};
@@ -13,10 +13,15 @@ use tauri::{AppHandle, Manager};
use tokio::sync::Mutex as AsyncMutex;
use crate::huddle::HuddleState;
pub(crate) use crate::identity_storage::{IdentityStorage, RecoveryState, ResolvedIdentity};
use crate::managed_agents::config_bridge::SessionConfigCache;
use crate::managed_agents::{ManagedAgentPairRuntime, ManagedAgentRuntimeKey};
pub struct AppState {
pub keys: Mutex<Keys>,
/// Durable backend holding `keys`. Updated after the key write and before
/// recovery flags are cleared so `get_identity` reports a consistent state.
pub(crate) identity_storage: AtomicU8,
pub http_client: reqwest::Client,
/// A no-redirect client for authenticated relay media fetches (download,
/// clipboard copy, snapshot, editor). Every caller pre-validates the URL
@@ -178,19 +183,20 @@ pub fn build_media_fetch_client() -> reqwest::Result<reqwest::Client> {
pub fn build_app_state() -> AppState {
// Env var takes precedence (dev/CI). If absent, resolve_persisted_identity()
// in setup() will replace the ephemeral placeholder with a persisted key.
let keys = match identity_from_env() {
let (keys, identity_storage) = match identity_from_env() {
Some(keys) => {
eprintln!(
"buzz-desktop: configured identity pubkey {}",
keys.public_key().to_hex()
);
keys
(keys, IdentityStorage::Environment)
}
None => Keys::generate(),
None => (Keys::generate(), IdentityStorage::Ephemeral),
};
AppState {
keys: Mutex::new(keys),
identity_storage: AtomicU8::new(identity_storage as u8),
http_client: reqwest::Client::builder()
.resolve("localhost", std::net::SocketAddr::from(([127, 0, 0, 1], 0)))
.pool_idle_timeout(std::time::Duration::from_secs(10))
@@ -366,9 +372,13 @@ pub fn resolve_persisted_identity(app: &AppHandle, state: &AppState) -> Result<(
std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?;
let resolved = load_or_create_identity(&data_dir)?;
// Write keys before setting the recovery flags (Release) so any thread
// that reads a flag as false with Acquire is guaranteed to see the keys.
*state.keys.lock().map_err(|e| e.to_string())? = resolved.keys;
// Write keys and storage before setting the recovery flags (Release) so
// any thread that reads a flag as false with Acquire sees consistent data.
{
let mut active_keys = state.keys.lock().map_err(|e| e.to_string())?;
*active_keys = resolved.keys;
state.set_identity_storage(resolved.storage);
}
state.identity_lost.store(
resolved.recovery == RecoveryState::Lost,
std::sync::atomic::Ordering::Release,
@@ -394,26 +404,6 @@ const IDENTITY_KEY_NAME: &str = "identity";
/// keyring is merely unreachable (the key IS in the keyring, must NOT generate).
const MIGRATION_MARKER_NAME: &str = "identity.migrated";
/// Recovery state produced by identity resolution. `None` means the app has
/// a real, usable identity. `Lost` means the keyring was reachable-but-empty
/// despite a prior successful migration — the key vanished externally. `KeyringLocked`
/// means the keyring is unreachable this boot but was used in the past
/// (marker present, no file) — the key still exists but is temporarily
/// inaccessible. Both non-`None` variants boot with an ephemeral key; the
/// frontend shows a different recovery screen for each.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RecoveryState {
None,
Lost,
KeyringLocked,
}
/// The output of identity resolution.
struct ResolvedIdentity {
keys: Keys,
recovery: RecoveryState,
}
/// The keyring operations the identity resolution flow needs. Abstracted so the
/// corrupt-keyring recovery decision ([`recover_from_keyring`]) can be
/// unit-tested against a fake without touching the live OS keyring.
@@ -465,6 +455,7 @@ fn load_or_create_identity(data_dir: &std::path::Path) -> Result<ResolvedIdentit
return Ok(ResolvedIdentity {
keys,
recovery: RecoveryState::None,
storage: IdentityStorage::LocalFile,
});
}
@@ -514,7 +505,7 @@ fn resolve_identity_with_store(
// fails. A transient keyring failure must not abort
// boot — the file key is safe and adoption retries next
// boot when the keyring is reachable again.
if let Err(e) = persist_identity_to_keyring(
let storage = if let Err(e) = persist_identity_to_keyring(
store,
&file_keys,
legacy_path,
@@ -524,10 +515,14 @@ fn resolve_identity_with_store(
"buzz-desktop: keyring adoption of identity.key \
failed ({e}); using file key, will retry next boot"
);
}
IdentityStorage::LocalFile
} else {
IdentityStorage::SystemKeyring
};
return Ok(ResolvedIdentity {
keys: file_keys,
recovery: RecoveryState::None,
storage,
});
}
// Corrupt file — keyring is authoritative. Log before
@@ -567,6 +562,7 @@ fn resolve_identity_with_store(
return Ok(ResolvedIdentity {
keys: keyring_keys,
recovery: RecoveryState::None,
storage: IdentityStorage::SystemKeyring,
});
}
// The corruption is in the KEYRING, not the file. Clear the
@@ -595,6 +591,7 @@ fn resolve_identity_with_store(
return Ok(ResolvedIdentity {
keys,
recovery: RecoveryState::None,
storage: IdentityStorage::SystemKeyring,
});
}
} else if migration_marker_path(data_dir).exists() {
@@ -615,6 +612,7 @@ fn resolve_identity_with_store(
return Ok(ResolvedIdentity {
keys: ephemeral,
recovery: RecoveryState::Lost,
storage: IdentityStorage::Ephemeral,
});
}
}
@@ -643,20 +641,23 @@ fn resolve_identity_with_store(
return Ok(ResolvedIdentity {
keys: ephemeral,
recovery: RecoveryState::KeyringLocked,
storage: IdentityStorage::Ephemeral,
});
}
let keys = load_file_or_generate(legacy_path, data_dir)?;
return Ok(ResolvedIdentity {
keys,
recovery: RecoveryState::None,
storage: IdentityStorage::LocalFile,
});
}
}
let keys = generate_and_persist(store, legacy_path, data_dir)?;
let (keys, storage) = generate_and_persist(store, legacy_path, data_dir)?;
Ok(ResolvedIdentity {
keys,
recovery: RecoveryState::None,
storage,
})
}
@@ -682,6 +683,7 @@ fn recover_from_keyring(
return Ok(ResolvedIdentity {
keys,
recovery: RecoveryState::None,
storage: IdentityStorage::SystemKeyring,
});
}
}
@@ -699,13 +701,15 @@ fn recover_from_keyring(
return Ok(ResolvedIdentity {
keys: ephemeral,
recovery: RecoveryState::Lost,
storage: IdentityStorage::Ephemeral,
});
}
// No marker: genuine first launch with a corrupt keyring. Generate fresh.
let keys = generate_and_persist(store, legacy_path, data_dir)?;
let (keys, storage) = generate_and_persist(store, legacy_path, data_dir)?;
Ok(ResolvedIdentity {
keys,
recovery: RecoveryState::None,
storage,
})
}
@@ -872,15 +876,16 @@ fn persist_imported_identity_impl(
keys: &Keys,
legacy_path: &std::path::Path,
data_dir: &std::path::Path,
) -> Result<(), String> {
) -> Result<IdentityStorage, String> {
match persist_identity_to_keyring(store, keys, legacy_path, data_dir) {
Ok(()) => Ok(()),
Ok(()) => Ok(IdentityStorage::SystemKeyring),
Err(e) => {
eprintln!(
"buzz-desktop: keyring write failed during import ({e}), \
falling back to identity.key"
);
save_key_file(legacy_path, keys)
save_key_file(legacy_path, keys)?;
Ok(IdentityStorage::LocalFile)
}
}
}
@@ -892,7 +897,7 @@ pub(crate) fn persist_imported_identity(
keys: &Keys,
legacy_path: &std::path::Path,
data_dir: &std::path::Path,
) -> Result<(), String> {
) -> Result<IdentityStorage, String> {
persist_imported_identity_impl(store, keys, legacy_path, data_dir)
}
@@ -920,15 +925,6 @@ fn write_migration_marker(marker_path: &std::path::Path) -> Result<(), String> {
.map_err(|e| format!("commit migration marker: {e}"))
}
/// Which backend [`store_key_preferring_keyring`] wrote to. The caller writes
/// the migration marker only after a keyring success — on the file-fallback arm
/// the key is on disk and a marker would wrongly trip the next Unreachable boot
/// into failing closed.
enum PersistBackend {
Keyring,
File,
}
/// Generate a fresh identity, persist it through the store, return it.
///
/// On a keyring-backed persist no file is written, so a later
@@ -940,9 +936,10 @@ fn generate_and_persist(
store: &impl IdentityKeyStore,
legacy_path: &std::path::Path,
data_dir: &std::path::Path,
) -> Result<Keys, String> {
) -> Result<(Keys, IdentityStorage), String> {
let keys = Keys::generate();
if let PersistBackend::Keyring = store_key_preferring_keyring(store, &keys, legacy_path)? {
let storage = store_key_preferring_keyring(store, &keys, legacy_path)?;
if storage == IdentityStorage::SystemKeyring {
let marker_path = migration_marker_path(data_dir);
if let Err(e) = write_migration_marker(&marker_path) {
eprintln!(
@@ -956,7 +953,7 @@ fn generate_and_persist(
"buzz-desktop: generated and saved identity pubkey {}",
keys.public_key().to_hex()
);
Ok(keys)
Ok((keys, storage))
}
/// Persist `keys` through the store, silently falling back to the `0o600` file
@@ -968,17 +965,17 @@ fn store_key_preferring_keyring(
store: &impl IdentityKeyStore,
keys: &Keys,
legacy_path: &std::path::Path,
) -> Result<PersistBackend, String> {
) -> Result<IdentityStorage, String> {
let nsec = keys
.secret_key()
.to_bech32()
.map_err(|e| format!("encode nsec: {e}"))?;
match store.store(IDENTITY_KEY_NAME, &nsec) {
Ok(()) => Ok(PersistBackend::Keyring),
Ok(()) => Ok(IdentityStorage::SystemKeyring),
Err(keyring_err) => {
eprintln!("buzz-desktop: keyring write failed ({keyring_err}), using file fallback");
save_key_file(legacy_path, keys)?;
Ok(PersistBackend::File)
Ok(IdentityStorage::LocalFile)
}
}
}
+6 -6
View File
@@ -484,7 +484,7 @@ fn fresh_keyring_generate_writes_marker() {
let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap();
// The key was stored in the keyring (not the file), and the marker marks it.
assert!(!legacy_path.exists());
assert!(!legacy_path.exists() && resolved.storage == IdentityStorage::SystemKeyring);
assert!(migration_marker_path(dir.path()).exists());
assert_eq!(
store
@@ -541,7 +541,10 @@ fn fresh_generate_keyring_failure_falls_back_to_file_without_marker() {
let from_file = load_key_file(&legacy_path).unwrap();
assert_key_eq(&resolved.keys, &from_file);
// No marker: the file is the authoritative store, not the keyring.
assert!(!migration_marker_path(dir.path()).exists());
assert!(
!migration_marker_path(dir.path()).exists()
&& resolved.storage == IdentityStorage::LocalFile
);
}
// ── New tests for the three defects fixed in this PR ─────────────────────
@@ -786,10 +789,7 @@ fn persist_imported_identity_falls_back_to_file_on_keyring_failure() {
let result = persist_imported_identity_impl(&store, &imported_keys, &legacy_path, dir.path());
// The policy core handles the keyring failure — Ok, not Err.
assert!(
result.is_ok(),
"must not propagate keyring failure when file fallback succeeds"
);
assert_eq!(result.unwrap(), IdentityStorage::LocalFile);
// Key is recoverable from the file on next boot.
let from_file = load_key_file(&legacy_path).unwrap();
+87 -30
View File
@@ -43,6 +43,7 @@ pub fn get_identity(state: State<'_, AppState>) -> Result<IdentityInfo, String>
Ok(IdentityInfo {
pubkey: pubkey_hex,
display_name,
storage: state.identity_storage().as_str().to_string(),
lost,
locked,
reset_failed,
@@ -334,11 +335,17 @@ pub async fn save_ncryptsec_copy(
#[tauri::command]
pub async fn import_identity(
nsec: String,
password: Option<String>,
app_handle: tauri::AppHandle,
) -> Result<IdentityInfo, String> {
tokio::task::spawn_blocking(move || {
let trimmed = nsec.trim();
let keys = Keys::parse(trimmed).map_err(|e| format!("Invalid private key: {e}"))?;
// NIP-49 backups require a passphrase and decrypt entirely in Rust.
// Raw nsec/hex input follows the existing parser path unchanged.
let password = password.map(zeroize::Zeroizing::new);
let keys = crate::key_backup::recover_keys_from_input(
&nsec,
password.as_ref().map(|value| value.as_str()),
)?;
// Serialize against persist_current_identity: hold this guard for the
// full function body so a concurrent stale persist can't overwrite
@@ -353,30 +360,14 @@ pub async fn import_identity(
std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?;
let key_path = data_dir.join("identity.key");
// Persist into the OS keyring first (store → read-back verify → marker →
// delete file). Falls back to the 0o600 file when the keyring is
// unavailable; returns Err only when both backends fail.
let store = crate::secret_store::SecretStore::shared(crate::app_state::keyring_service());
crate::app_state::persist_imported_identity(store, &keys, &key_path, &data_dir)?;
// Update in-memory keys BEFORE clearing recovery flags. The Release
// stores below pair with Acquire loads in get_identity: a reader
// observing false is guaranteed to see the updated keys.
let pubkey = keys.public_key();
*state.keys.lock().map_err(|e| e.to_string())? = keys;
// Clear both recovery flags — an import is valid in either lost or
// keyring-locked state and resolves both. In the locked case the
// keyring is unreachable, so persist_imported_identity already fell
// back to identity.key; on the next Unreachable boot the file is
// loaded directly and when the keyring returns the adoption path
// picks it up.
state
.identity_lost
.store(false, std::sync::atomic::Ordering::Release);
state
.keyring_locked
.store(false, std::sync::atomic::Ordering::Release);
let (pubkey, storage) = commit_imported_identity(&state, &data_dir, keys, |keys| {
// Persist into the OS keyring first (store → read-back verify →
// marker → delete file). Falls back to the 0o600 file when the
// keyring is unavailable; returns Err only when both backends fail.
let store =
crate::secret_store::SecretStore::shared(crate::app_state::keyring_service());
crate::app_state::persist_imported_identity(store, keys, &key_path, &data_dir)
})?;
let pubkey_hex = pubkey.to_hex();
let display_name = truncated_display_name(&pubkey)?;
@@ -386,6 +377,7 @@ pub async fn import_identity(
Ok(IdentityInfo {
pubkey: pubkey_hex,
display_name,
storage: storage.as_str().to_string(),
lost: false,
locked: false,
reset_failed: false,
@@ -395,6 +387,69 @@ pub async fn import_identity(
.map_err(|e| format!("spawn_blocking failed: {e}"))?
}
/// Commit an imported identity: durably persist, swap in-memory keys, clear
/// recovery flags, then remove the previous identity's stale app-managed
/// backup. Caller must hold `state.identity_mutation`.
///
/// Ordering is the contract:
///
/// 1. `persist` runs FIRST. If it fails (`Err` from both keyring and file
/// fallback), nothing has changed — the previous identity stays live in
/// memory AND its valid canonical `identity.ncryptsec` stays on disk.
/// 2. Only after durable persistence do we swap `state.keys` and clear the
/// recovery flags.
/// 3. Stale-backup cleanup runs LAST and is deliberately best-effort: at that
/// point the import is durably committed, so reporting a cleanup failure
/// as a command `Err` would claim a half-applied import that actually
/// succeeded. The leftover blob is still passphrase-encrypted and is
/// replaced by the next backup creation; we log and move on.
fn commit_imported_identity(
state: &AppState,
data_dir: &std::path::Path,
keys: nostr::Keys,
persist: impl FnOnce(&nostr::Keys) -> Result<crate::app_state::IdentityStorage, String>,
) -> Result<(nostr::PublicKey, crate::app_state::IdentityStorage), String> {
// Capture the previous pubkey up front for post-commit cleanup.
let previous_pubkey = state.keys.lock().map_err(|e| e.to_string())?.public_key();
let storage = persist(&keys)?;
// Update in-memory keys BEFORE clearing recovery flags. The Release
// stores below pair with Acquire loads in get_identity: a reader
// observing false is guaranteed to see the updated keys.
let pubkey = keys.public_key();
{
let mut active_keys = state.keys.lock().map_err(|e| e.to_string())?;
*active_keys = keys;
state.set_identity_storage(storage);
}
// Clear both recovery flags — an import is valid in either lost or
// keyring-locked state and resolves both. In the locked case the
// keyring is unreachable, so the persist step already fell back to
// identity.key; on the next Unreachable boot the file is loaded
// directly and when the keyring returns the adoption path picks it up.
state
.identity_lost
.store(false, std::sync::atomic::Ordering::Release);
state
.keyring_locked
.store(false, std::sync::atomic::Ordering::Release);
// Importing a different identity invalidates the app-managed backup: it
// encrypts the previous key and must not linger mislabeled. Best-effort
// per the ordering contract above.
if let Err(e) = crate::key_backup::cleanup_stale_backup(&previous_pubkey, &pubkey, data_dir) {
eprintln!(
"buzz-desktop: import committed, but stale key backup cleanup failed: {e}; \
the leftover identity.ncryptsec encrypts the PREVIOUS key and will be \
replaced by the next backup creation"
);
}
Ok((pubkey, storage))
}
/// Make the current ephemeral identity durable by persisting it to the OS
/// keyring (or falling back to identity.key). This is called when the user
/// chooses to start a new identity instead of re-importing their previous one
@@ -438,11 +493,12 @@ pub async fn persist_current_identity(
let key_path = data_dir.join("identity.key");
let store = crate::secret_store::SecretStore::shared(crate::app_state::keyring_service());
crate::app_state::persist_imported_identity(store, &keys, &key_path, &data_dir)?;
let storage =
crate::app_state::persist_imported_identity(store, &keys, &key_path, &data_dir)?;
// Keys are already the live identity — only clear identity_lost.
// Release pairs with Acquire in get_identity so readers see
// consistent state.
// Keys are already the live identity. Record where the durable write
// landed before clearing identity_lost.
state.set_identity_storage(storage);
state
.identity_lost
.store(false, std::sync::atomic::Ordering::Release);
@@ -454,6 +510,7 @@ pub async fn persist_current_identity(
Ok(IdentityInfo {
pubkey: pubkey_hex,
display_name,
storage: storage.as_str().to_string(),
lost: false,
locked: false,
reset_failed: false,
+62
View File
@@ -0,0 +1,62 @@
use nostr::Keys;
use crate::app_state::AppState;
/// Durable location of the active human identity.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub(crate) enum IdentityStorage {
Ephemeral = 0,
SystemKeyring = 1,
LocalFile = 2,
Environment = 3,
}
impl IdentityStorage {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Ephemeral => "ephemeral",
Self::SystemKeyring => "system-keyring",
Self::LocalFile => "local-file",
Self::Environment => "environment",
}
}
fn from_u8(value: u8) -> Self {
match value {
1 => Self::SystemKeyring,
2 => Self::LocalFile,
3 => Self::Environment,
_ => Self::Ephemeral,
}
}
}
impl AppState {
pub(crate) fn identity_storage(&self) -> IdentityStorage {
IdentityStorage::from_u8(
self.identity_storage
.load(std::sync::atomic::Ordering::Acquire),
)
}
pub(crate) fn set_identity_storage(&self, storage: IdentityStorage) {
self.identity_storage
.store(storage as u8, std::sync::atomic::Ordering::Release);
}
}
/// Recovery state produced by identity resolution.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RecoveryState {
None,
Lost,
KeyringLocked,
}
/// Identity and persistence metadata produced by startup resolution.
pub(crate) struct ResolvedIdentity {
pub(crate) keys: Keys,
pub(crate) recovery: RecoveryState,
pub(crate) storage: IdentityStorage,
}
+47
View File
@@ -13,6 +13,10 @@
use nostr::nips::nip49::{EncryptedSecretKey, KeySecurity};
use nostr::{FromBech32, Keys, ToBech32};
/// Bech32 prefix of NIP-49 encrypted secret keys. Import routing is
/// case-insensitive because bech32 permits all-uppercase encodings.
pub const NCRYPTSEC_HRP: &str = "ncryptsec1";
/// scrypt cost for new backups (2^18 — Gossip's desktop default, ~256 MiB).
/// The blob self-describes its cost, so this can be raised later without
/// breaking existing backups.
@@ -108,6 +112,27 @@ pub fn decrypt_ncryptsec(input: &str, password: &str) -> Result<Keys, String> {
Ok(Keys::new(secret_key))
}
/// Recover identity keys from either an encrypted NIP-49 backup or the raw
/// nsec/hex formats accepted before encrypted imports were added.
pub fn recover_keys_from_input(input: &str, password: Option<&str>) -> Result<Keys, String> {
let trimmed = input.trim();
let is_ncryptsec = trimmed
.get(..NCRYPTSEC_HRP.len())
.is_some_and(|prefix| prefix.eq_ignore_ascii_case(NCRYPTSEC_HRP));
if is_ncryptsec {
let password = password.ok_or_else(|| "key backup requires a password".to_string())?;
decrypt_ncryptsec(trimmed, password)
} else {
Keys::parse(trimmed).map_err(|e| format!("Invalid private key: {e}"))
}
}
/// Path of the canonical app-managed backup file.
pub fn backup_file_path(data_dir: &std::path::Path) -> std::path::PathBuf {
data_dir.join(BACKUP_FILE_NAME)
}
/// Atomically write `ncryptsec` to `path` with owner-only permissions, then
/// reread and byte-compare. Same crash-safety pattern as
/// `app_state::save_key_file`.
@@ -140,6 +165,28 @@ pub fn write_backup_file(path: &std::path::Path, ncryptsec: &str) -> Result<(),
Ok(())
}
/// Delete the app-managed backup if present. Missing files are already clean.
pub fn delete_backup_file(data_dir: &std::path::Path) -> Result<(), String> {
let path = backup_file_path(data_dir);
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(format!("delete stale backup file: {e}")),
}
}
/// Remove the app-managed backup only when an import changes identities.
pub fn cleanup_stale_backup(
previous: &nostr::PublicKey,
new: &nostr::PublicKey,
data_dir: &std::path::Path,
) -> Result<(), String> {
if previous != new {
delete_backup_file(data_dir)?;
}
Ok(())
}
/// Generate a passphrase of `word_count` EFF short-wordlist words joined by
/// `separator`, using OS entropy.
///
+77 -2
View File
@@ -79,12 +79,59 @@ fn verify_backup_blob_catches_pubkey_mismatch() {
assert!(err.contains("does not match identity"), "{err}");
}
// ── Import key recovery ───────────────────────────────────────────────────────
#[test]
fn recover_keys_ncryptsec_happy_path() {
let keys = recover_keys_from_input(&format!(" {SPEC_NCRYPTSEC}\n"), Some("nostr")).unwrap();
assert_eq!(keys.secret_key().to_secret_hex(), SPEC_SECRET_HEX);
}
#[test]
fn recover_keys_ncryptsec_requires_password() {
let err = recover_keys_from_input(SPEC_NCRYPTSEC, None).unwrap_err();
assert_eq!(err, "key backup requires a password");
}
#[test]
fn recover_keys_ncryptsec_wrong_password() {
let err = recover_keys_from_input(SPEC_NCRYPTSEC, Some("wrong")).unwrap_err();
assert_eq!(err, "wrong backup password or damaged key backup");
}
#[test]
fn recover_keys_uppercase_ncryptsec_classifies_as_encrypted() {
let upper = SPEC_NCRYPTSEC.to_ascii_uppercase();
assert_eq!(
recover_keys_from_input(&upper, None).unwrap_err(),
"key backup requires a password"
);
let keys = recover_keys_from_input(&upper, Some("nostr")).unwrap();
assert_eq!(keys.secret_key().to_secret_hex(), SPEC_SECRET_HEX);
let mut mixed = SPEC_NCRYPTSEC.to_string();
mixed.replace_range(0..1, "N");
let err = recover_keys_from_input(&mixed, Some("nostr")).unwrap_err();
assert!(err.contains("invalid ncryptsec"), "{err}");
}
#[test]
fn recover_keys_raw_nsec_path_unchanged() {
let keys = Keys::generate();
let nsec = keys.secret_key().to_bech32().unwrap();
let recovered = recover_keys_from_input(&nsec, Some("ignored")).unwrap();
assert_eq!(recovered.public_key(), keys.public_key());
let recovered = recover_keys_from_input(&nsec, None).unwrap();
assert_eq!(recovered.public_key(), keys.public_key());
assert!(recover_keys_from_input("garbage", None).is_err());
}
// ── File lifecycle ────────────────────────────────────────────────────────────
#[test]
fn write_backup_file_persists_0600_and_verifies() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(BACKUP_FILE_NAME);
let path = backup_file_path(dir.path());
write_backup_file(&path, SPEC_NCRYPTSEC).unwrap();
let on_disk = std::fs::read_to_string(&path).unwrap();
@@ -101,7 +148,7 @@ fn write_backup_file_persists_0600_and_verifies() {
#[test]
fn write_backup_file_overwrites_atomically() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(BACKUP_FILE_NAME);
let path = backup_file_path(dir.path());
write_backup_file(&path, "ncryptsec1old").unwrap();
write_backup_file(&path, SPEC_NCRYPTSEC).unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), SPEC_NCRYPTSEC);
@@ -113,6 +160,34 @@ fn write_backup_file_overwrites_atomically() {
assert_eq!(entries, vec![std::ffi::OsString::from(BACKUP_FILE_NAME)]);
}
#[test]
fn delete_backup_file_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
delete_backup_file(dir.path()).unwrap();
let path = backup_file_path(dir.path());
write_backup_file(&path, SPEC_NCRYPTSEC).unwrap();
delete_backup_file(dir.path()).unwrap();
assert!(!path.exists());
}
#[test]
fn cleanup_stale_backup_removes_only_on_identity_change() {
let dir = tempfile::tempdir().unwrap();
let path = backup_file_path(dir.path());
let a = Keys::generate().public_key();
let b = Keys::generate().public_key();
write_backup_file(&path, SPEC_NCRYPTSEC).unwrap();
cleanup_stale_backup(&a, &a, dir.path()).unwrap();
assert!(path.exists(), "same identity must keep the backup");
cleanup_stale_backup(&a, &b, dir.path()).unwrap();
assert!(
!path.exists(),
"identity change must remove the stale backup"
);
}
#[test]
fn generated_passphrase_respects_word_count_and_separator() {
let words: std::collections::HashSet<&str> =
+1
View File
@@ -8,6 +8,7 @@ mod egress_guard;
mod event_sync;
mod events;
mod huddle;
mod identity_storage;
mod key_backup;
mod linux_media;
mod managed_agents;
+2
View File
@@ -6,6 +6,8 @@ use serde::{Deserialize, Deserializer, Serialize};
pub struct IdentityInfo {
pub pubkey: String,
pub display_name: String,
/// Durable location of the active identity key.
pub storage: String,
/// True when the app booted with an ephemeral key because the OS keyring
/// was empty despite a prior successful migration (key was externally
/// deleted). The frontend routes to the nsec re-import step when true.
+20
View File
@@ -463,6 +463,26 @@ mod tests {
assert_eq!(kc.delete_calls.get(), 1, "keychain deleted once");
}
// ── NIP-49: the boot wipe destroys the app-managed key backup ─────────────
#[test]
fn test_wipe_removes_app_managed_key_backup() {
let tmp = TempDir::new().unwrap();
let app_data = make_app_data(&tmp);
let backup = crate::key_backup::backup_file_path(&app_data);
std::fs::write(&backup, b"encrypted-backup-bytes").unwrap();
write_sentinel(&app_data).unwrap();
let kc = FakeKeychain::ok();
let outcome = run_boot_reset_with_keychain(make_ctx(&app_data, &kc, false));
assert!(outcome.completed);
assert!(
!backup.exists(),
"sign-out wipe must destroy the app-managed key backup"
);
}
// ── Test 3: keychain failure keeps sentinel ────────────────────────────────
#[test]
@@ -0,0 +1,118 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
MIN_PASSPHRASE_LEN,
downloadDisabled,
isEncrypting,
passphraseIssue,
pendingEncryptPassphrase,
effectivePassphrase,
encryptedBackupReducer,
initialEncryptedBackupState,
} from "./encryptedBackup.ts";
const reduce = (events, from = initialEncryptedBackupState) =>
events.reduce(encryptedBackupReducer, from);
test("password validation mirrors Rust character counting", () => {
assert.equal(passphraseIssue(""), null);
assert.match(passphraseIssue("short"), new RegExp(`${MIN_PASSPHRASE_LEN}`));
const emoji = "😀".repeat(MIN_PASSPHRASE_LEN);
assert.equal(passphraseIssue(emoji), null);
assert.equal(
effectivePassphrase(reduce([{ type: "set-passphrase", value: emoji }])),
emoji,
);
});
test("valid password requests encryption without copying it into events", () => {
const ready = reduce([
{ type: "set-passphrase", value: "one-two-three-four" },
]);
assert.equal(pendingEncryptPassphrase(ready), "one-two-three-four");
const started = reduce([{ type: "encrypt-started", requestId: 1 }], ready);
assert.equal(isEncrypting(started), true);
assert.equal(started.requestId, 1);
assert.equal(Object.hasOwn(started, "encryptingPassphrase"), false);
});
test("background encryption remains silent until download is clicked", () => {
const state = reduce([
{ type: "set-passphrase", value: "one-two-three-four" },
{ type: "encrypt-started", requestId: 1 },
{ type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1abc" },
]);
assert.equal(state.passphrase, "one-two-three-four");
assert.equal(state.encrypted, "ncryptsec1abc");
assert.equal(state.ncryptsec, null);
assert.equal(state.savedPassword, false);
assert.equal(state.requestId, null);
});
test("stale async completions cannot replace current request", () => {
const state = reduce([
{ type: "set-passphrase", value: "one-two-three-four" },
{ type: "encrypt-started", requestId: 1 },
{ type: "set-passphrase", value: "five-six-seven-eight" },
{ type: "encrypt-started", requestId: 2 },
{ type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1stale" },
]);
assert.equal(state.requestId, 2);
assert.equal(state.encrypted, null);
assert.equal(state.passphrase, "five-six-seven-eight");
});
test("failure clears submitted password", () => {
const state = reduce([
{ type: "set-passphrase", value: "one-two-three-four" },
{ type: "encrypt-started", requestId: 1 },
{ type: "download-clicked" },
{ type: "encrypt-failed", requestId: 1, message: "keychain unavailable" },
]);
assert.equal(state.passphrase, "");
assert.equal(state.createError, "keychain unavailable");
assert.equal(state.downloadPending, false);
assert.equal(downloadDisabled(state), true);
});
test("queued download commits and clears password", () => {
const state = reduce([
{ type: "set-passphrase", value: "one-two-three-four" },
{ type: "encrypt-started", requestId: 1 },
{ type: "download-clicked" },
{ type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1abc" },
]);
assert.equal(state.ncryptsec, "ncryptsec1abc");
assert.equal(state.passphrase, "");
assert.equal(state.savedPassword, true);
});
test("Back preserves blob for immediate re-download without password", () => {
const made = reduce([
{ type: "set-passphrase", value: "one-two-three-four" },
{ type: "encrypt-started", requestId: 1 },
{ type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1abc" },
{ type: "download-clicked" },
{ type: "back-to-password" },
]);
assert.equal(made.ncryptsec, "ncryptsec1abc");
assert.equal(made.passphrase, "");
assert.equal(downloadDisabled(made), false);
});
test("starting over discards blob and invalidates late requests", () => {
const made = {
...initialEncryptedBackupState,
ncryptsec: "ncryptsec1abc",
encrypted: "ncryptsec1abc",
savedPassword: true,
nextRequestId: 3,
};
const fresh = reduce([{ type: "start-new-backup" }], made);
assert.equal(fresh.ncryptsec, null);
assert.equal(fresh.nextRequestId, 4);
assert.equal(
reduce(
[
{
type: "encrypt-succeeded",
requestId: 2,
ncryptsec: "ncryptsec1stale",
},
],
fresh,
).ncryptsec,
null,
);
});
@@ -0,0 +1,135 @@
/** Pure state model for NIP-49 backup creation. */
export const MIN_PASSPHRASE_LEN = 12;
export type EncryptedBackupState = {
passphrase: string;
requestId: number | null;
nextRequestId: number;
encrypted: string | null;
createError: string | null;
downloadPending: boolean;
ncryptsec: string | null;
savedPassword: boolean;
};
export const initialEncryptedBackupState: EncryptedBackupState = {
passphrase: "",
requestId: null,
nextRequestId: 1,
encrypted: null,
createError: null,
downloadPending: false,
ncryptsec: null,
savedPassword: false,
};
export type EncryptedBackupEvent =
| { type: "set-passphrase"; value: string }
| { type: "encrypt-started"; requestId: number }
| { type: "encrypt-succeeded"; requestId: number; ncryptsec: string }
| { type: "encrypt-failed"; requestId: number; message: string }
| { type: "download-clicked" }
| { type: "back-to-password" }
| { type: "start-new-backup" };
export function encryptedBackupReducer(
state: EncryptedBackupState,
event: EncryptedBackupEvent,
): EncryptedBackupState {
switch (event.type) {
case "set-passphrase":
return {
...state,
passphrase: event.value,
encrypted: null,
createError: null,
};
case "encrypt-started":
return {
...state,
requestId: event.requestId,
nextRequestId: Math.max(state.nextRequestId, event.requestId + 1),
createError: null,
};
case "encrypt-succeeded":
if (event.requestId !== state.requestId) return state;
if (state.downloadPending) {
return {
...state,
passphrase: "",
requestId: null,
encrypted: event.ncryptsec,
ncryptsec: event.ncryptsec,
downloadPending: false,
savedPassword: true,
};
}
return {
...state,
requestId: null,
encrypted: event.ncryptsec,
};
case "encrypt-failed":
if (event.requestId !== state.requestId) return state;
return {
...state,
passphrase: "",
requestId: null,
createError: event.message,
downloadPending: false,
};
case "download-clicked":
if (
state.ncryptsec ||
state.downloadPending ||
(!state.encrypted && !effectivePassphrase(state))
)
return state;
return state.encrypted
? {
...state,
ncryptsec: state.encrypted,
passphrase: "",
savedPassword: true,
}
: { ...state, downloadPending: true };
case "back-to-password":
return { ...state, createError: null };
case "start-new-backup":
return {
...initialEncryptedBackupState,
nextRequestId: state.nextRequestId + 1,
};
}
}
export function passphraseIssue(passphrase: string): string | null {
if (passphrase.length === 0) return null;
return [...passphrase].length < MIN_PASSPHRASE_LEN
? `Use at least ${MIN_PASSPHRASE_LEN} characters.`
: null;
}
export function effectivePassphrase(
state: EncryptedBackupState,
): string | null {
return [...state.passphrase].length < MIN_PASSPHRASE_LEN
? null
: state.passphrase;
}
export function pendingEncryptPassphrase(
state: EncryptedBackupState,
): string | null {
if (state.savedPassword || state.encrypted || state.requestId !== null)
return null;
return effectivePassphrase(state);
}
export function isEncrypting(state: EncryptedBackupState): boolean {
return state.requestId !== null;
}
export function downloadDisabled(state: EncryptedBackupState): boolean {
if (state.savedPassword && state.ncryptsec) return false;
return (
state.downloadPending ||
(!state.encrypted && effectivePassphrase(state) === null)
);
}
@@ -0,0 +1,73 @@
/**
* Pure-logic tests for key-import input classification (nsec vs NIP-49
* ncryptsec) and submit gating.
*/
import assert from "node:assert/strict";
import test from "node:test";
import { nsecEncode } from "nostr-tools/nip19";
import { generateSecretKey } from "nostr-tools/pure";
import {
classifyKeyImportInput,
isPlausibleNcryptsec,
keyImportSubmitEnabled,
NCRYPTSEC_ENCODED_LENGTH,
} from "./keyImportInput.ts";
// NIP-49 spec vector — structurally valid encrypted backup.
const NCRYPTSEC =
"ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p";
const VALID_NSEC = nsecEncode(generateSecretKey());
test("classify_by_hrp_with_whitespace_tolerance", () => {
assert.equal(classifyKeyImportInput(` ${NCRYPTSEC}\n`), "ncryptsec");
assert.equal(classifyKeyImportInput(VALID_NSEC), "nsec");
assert.equal(classifyKeyImportInput("npub1whatever"), "unknown");
assert.equal(classifyKeyImportInput(""), "unknown");
// nsec must not be shadowed by the longer HRP check.
assert.equal(classifyKeyImportInput("nsec1"), "nsec");
});
test("uppercase_bech32_encoding_classifies_and_gates_like_lowercase", () => {
// Bech32 permits an all-uppercase encoding; it must route to the
// encrypted path (matching Rust) and be submit-plausible.
const upper = NCRYPTSEC.toUpperCase();
assert.equal(classifyKeyImportInput(upper), "ncryptsec");
assert.equal(isPlausibleNcryptsec(upper), true);
assert.equal(keyImportSubmitEnabled(upper, ""), false);
assert.equal(keyImportSubmitEnabled(upper, "hunter2hunter2"), true);
// Mixed case: routed encrypted (Rust reports the accurate error) but
// never plausible/submittable — mixed-case bech32 cannot decode.
const mixed = `N${NCRYPTSEC.slice(1)}`;
assert.equal(classifyKeyImportInput(mixed), "ncryptsec");
assert.equal(isPlausibleNcryptsec(mixed), false);
assert.equal(keyImportSubmitEnabled(mixed, "hunter2hunter2"), false);
});
test("plausible_ncryptsec_requires_complete_checksummed_nip49_payload", () => {
assert.equal(NCRYPTSEC.length, NCRYPTSEC_ENCODED_LENGTH);
assert.equal(isPlausibleNcryptsec(NCRYPTSEC), true);
assert.equal(isPlausibleNcryptsec(` ${NCRYPTSEC}\n`), true);
assert.equal(isPlausibleNcryptsec(NCRYPTSEC.slice(0, -1)), false);
assert.equal(isPlausibleNcryptsec(`${NCRYPTSEC}q`), false);
// Same length and charset, but a changed checksum must not advance the UI.
assert.equal(isPlausibleNcryptsec(`${NCRYPTSEC.slice(0, -1)}q`), false);
// '1' and 'b' / 'i' / 'o' are not in the Bech32 data charset.
assert.equal(isPlausibleNcryptsec("ncryptsec1bio"), false);
assert.equal(isPlausibleNcryptsec("ncryptsec1"), false);
assert.equal(isPlausibleNcryptsec("ncryptsec1 with spaces"), false);
});
test("submit_gating_nsec_path_unchanged", () => {
assert.equal(keyImportSubmitEnabled(VALID_NSEC, ""), true);
assert.equal(keyImportSubmitEnabled("nsec1garbage", ""), false);
assert.equal(keyImportSubmitEnabled("", ""), false);
});
test("submit_gating_ncryptsec_requires_passphrase", () => {
assert.equal(keyImportSubmitEnabled(NCRYPTSEC, ""), false);
assert.equal(keyImportSubmitEnabled(NCRYPTSEC, "hunter2hunter2"), true);
// Structurally implausible blob never submits, passphrase or not.
assert.equal(keyImportSubmitEnabled("ncryptsec1bio", "hunter2"), false);
});
@@ -0,0 +1,127 @@
/**
* Pure classification + submit gating for the key-import form, unit-testable
* without a DOM.
*
* `ncryptsec1…` is a NIP-49 encrypted backup: no npub preview is possible
* (the pubkey is inside the encrypted payload) and a passphrase is required.
* Password validation happens in Rust at decrypt time; this module performs
* the password-independent Bech32 and NIP-49 structure checks needed to decide
* when the form can safely switch modes.
*/
import { nsecToNpub } from "@/shared/lib/nostrUtils";
export type KeyImportKind = "nsec" | "ncryptsec" | "unknown";
const NCRYPTSEC_HRP = "ncryptsec";
const NIP49_VERSION = 2;
const NIP49_PAYLOAD_BYTES = 91;
/** Current NIP-49 payloads encode to 162 characters including the checksum. */
export const NCRYPTSEC_ENCODED_LENGTH = 162;
const BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
const BECH32_GENERATORS = [
0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3,
] as const;
function bech32Polymod(values: readonly number[]): number {
let checksum = 1;
for (const value of values) {
const high = checksum >>> 25;
checksum = ((checksum & 0x1ffffff) << 5) ^ value;
for (let index = 0; index < BECH32_GENERATORS.length; index += 1) {
if ((high >>> index) & 1) checksum ^= BECH32_GENERATORS[index];
}
}
return checksum >>> 0;
}
function expandBech32Hrp(hrp: string): number[] {
return [
...Array.from(hrp, (character) => character.charCodeAt(0) >>> 5),
0,
...Array.from(hrp, (character) => character.charCodeAt(0) & 31),
];
}
function convertFiveBitWordsToBytes(words: readonly number[]): number[] | null {
let accumulator = 0;
let bitCount = 0;
const bytes: number[] = [];
for (const word of words) {
accumulator = (accumulator << 5) | word;
bitCount += 5;
while (bitCount >= 8) {
bitCount -= 8;
bytes.push((accumulator >>> bitCount) & 0xff);
}
}
// Bech32 conversion without padding permits fewer than five zero remainder
// bits. Any larger or non-zero remainder is not a canonical byte encoding.
if (bitCount >= 5 || ((accumulator << (8 - bitCount)) & 0xff) !== 0) {
return null;
}
return bytes;
}
export function classifyKeyImportInput(input: string): KeyImportKind {
const trimmed = input.trim();
// Case-insensitive on the HRP to match the Rust classifier: an uppercase
// valid backup routes to the encrypted path (and decodes there); mixed
// case routes there too and fails in Rust with the accurate error.
if (trimmed.slice(0, 10).toLowerCase() === "ncryptsec1") return "ncryptsec";
if (trimmed.startsWith("nsec1")) return "nsec";
return "unknown";
}
/**
* Password-independent NIP-49 validation used for the automatic UI transition.
* A candidate must have canonical casing and length, a valid Bech32 checksum,
* and the current 91-byte/version-2 NIP-49 payload shape.
*/
export function isPlausibleNcryptsec(input: string): boolean {
const trimmed = input.trim();
if (trimmed.length !== NCRYPTSEC_ENCODED_LENGTH) return false;
if (trimmed !== trimmed.toLowerCase() && trimmed !== trimmed.toUpperCase()) {
return false;
}
const normalized = trimmed.toLowerCase();
const separatorIndex = normalized.lastIndexOf("1");
if (
separatorIndex !== NCRYPTSEC_HRP.length ||
normalized.slice(0, separatorIndex) !== NCRYPTSEC_HRP
) {
return false;
}
const encoded = normalized.slice(separatorIndex + 1);
const words = Array.from(encoded, (character) =>
BECH32_CHARSET.indexOf(character),
);
if (words.some((word) => word < 0) || words.length <= 6) return false;
if (bech32Polymod([...expandBech32Hrp(NCRYPTSEC_HRP), ...words]) !== 1) {
return false;
}
const payload = convertFiveBitWordsToBytes(words.slice(0, -6));
return (
payload?.length === NIP49_PAYLOAD_BYTES && payload[0] === NIP49_VERSION
);
}
/**
* Whether the import form's submit should be enabled.
* nsec: must derive an npub. ncryptsec: plausible blob + non-empty passphrase.
*/
export function keyImportSubmitEnabled(
input: string,
passphrase: string,
): boolean {
const kind = classifyKeyImportInput(input);
if (kind === "ncryptsec") {
return isPlausibleNcryptsec(input) && passphrase.length > 0;
}
return nsecToNpub(input) !== null;
}
@@ -0,0 +1,130 @@
import { FileKey2, LockKeyhole, LockOpen } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import { cn } from "@/shared/lib/cn";
const BACKUP_KEY_DOTS = [
"key-dot-1",
"key-dot-2",
"key-dot-3",
"key-dot-4",
"key-dot-5",
"key-dot-6",
"key-dot-7",
"key-dot-8",
"key-dot-9",
] as const;
const TIMELINE_CONNECTOR_DOTS = [
"connector-dot-1",
"connector-dot-2",
"connector-dot-3",
"connector-dot-4",
] as const;
const TIMELINE_DOT_INITIAL = { opacity: 0.35, scale: 0.85 };
const TIMELINE_DOT_PULSE = {
opacity: [0.35, 1, 0.35],
scale: [0.85, 1.25, 0.85],
};
const TIMELINE_DOT_TRANSITION = {
duration: 0.7,
ease: "easeInOut" as const,
repeat: Number.POSITIVE_INFINITY,
repeatDelay: 1.2,
};
const TIMELINE_TOP_DOT_TRANSITIONS = TIMELINE_CONNECTOR_DOTS.map(
(_, index) => ({
...TIMELINE_DOT_TRANSITION,
delay: index * 0.16,
}),
);
const TIMELINE_BOTTOM_DOT_TRANSITIONS = TIMELINE_CONNECTOR_DOTS.map(
(_, index) => ({
...TIMELINE_DOT_TRANSITION,
delay: (index + TIMELINE_CONNECTOR_DOTS.length) * 0.16 + 0.24,
}),
);
/**
* Decorative timeline shared by backup creation and encrypted-backup restore.
* Backup creation reads key password lock; restore reads encrypted file
* password unlocked account. The password field is layered over the center.
*/
export function BackupPasswordTimeline({
className,
mode = "backup",
}: {
className?: string;
mode?: "backup" | "restore";
}) {
const reduceMotion = useReducedMotion() ?? false;
return (
<div
aria-hidden
className={cn(
"pointer-events-none absolute inset-0 [@media(max-height:40rem)]:hidden",
className,
)}
data-testid="backup-password-timeline"
>
{mode === "restore" ? (
<div
className="absolute left-1/2 top-0 -translate-x-1/2 text-foreground/85"
data-testid="restore-ncryptsec-affordance"
>
<FileKey2 className="size-10" />
</div>
) : (
<div className="absolute inset-x-0 top-4 flex items-center justify-center gap-2">
{BACKUP_KEY_DOTS.map((dot) => (
<span
className="block size-2 rounded-full bg-foreground/85"
key={dot}
/>
))}
</div>
)}
<div
className={cn(
"absolute left-1/2 flex h-12 -translate-x-1/2 flex-col justify-between",
mode === "restore" ? "top-15" : "top-11",
)}
>
{TIMELINE_CONNECTOR_DOTS.map((dot, index) => (
<motion.span
animate={reduceMotion ? undefined : TIMELINE_DOT_PULSE}
className="block size-1.5 rounded-full bg-foreground/65"
initial={reduceMotion ? false : TIMELINE_DOT_INITIAL}
key={`top-${dot}`}
transition={
reduceMotion ? undefined : TIMELINE_TOP_DOT_TRANSITIONS[index]
}
/>
))}
</div>
<div className="absolute bottom-15 left-1/2 flex h-12 -translate-x-1/2 flex-col justify-between">
{TIMELINE_CONNECTOR_DOTS.map((dot, index) => (
<motion.span
animate={reduceMotion ? undefined : TIMELINE_DOT_PULSE}
className="block size-1.5 rounded-full bg-foreground/65"
initial={reduceMotion ? false : TIMELINE_DOT_INITIAL}
key={`bottom-${dot}`}
transition={
reduceMotion ? undefined : TIMELINE_BOTTOM_DOT_TRANSITIONS[index]
}
/>
))}
</div>
{mode === "restore" ? (
<LockOpen
className="absolute bottom-0 left-1/2 size-10 -translate-x-1/2 text-foreground/85"
data-testid="restore-unlock-icon"
/>
) : (
<LockKeyhole className="absolute bottom-0 left-1/2 size-10 -translate-x-1/2 text-foreground/85" />
)}
</div>
);
}
+375 -120
View File
@@ -1,183 +1,438 @@
import { AlertTriangle, Info, RefreshCw } from "lucide-react";
import { Check, Copy, Eye, EyeOff, Info, ShieldCheck } from "lucide-react";
import { useReducedMotion } from "motion/react";
import * as React from "react";
import { getNsec } from "@/shared/api/tauriIdentity";
import type { IdentityStorage } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { writeTextToClipboard } from "@/shared/lib/clipboard";
import { Button } from "@/shared/ui/button";
import { FuzzyLogo } from "@/shared/ui/buzz-logo/FuzzyLogo";
import { Card } from "@/shared/ui/card";
import { Spinner } from "@/shared/ui/spinner";
import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
import {
ONBOARDING_PRIMARY_CTA_CLASS,
ONBOARDING_SECONDARY_CTA_CLASS,
} from "./OnboardingChrome";
import { OnboardingFooter } from "./OnboardingFooter";
import {
type OnboardingTransitionDirection,
OnboardingSlideTransition,
} from "./OnboardingSlideTransition";
import { NsecMaskedDisplay } from "./NsecMaskedDisplay";
import { ONBOARDING_KEY_TEXT_CLASS } from "./NsecMaskedDisplay";
/**
* Pure helper so the disabled logic can be unit-tested without a DOM.
*
* Disabled while loading (key not fetched yet) or after a failed load (only
* the explicit "Skip for now" ghost advances past an error).
* How long the "Creating your identity key" loader holds the stage before the
* finished state fades in. Purely perceptual the key already exists; the
* pause sells the creation moment.
*/
export function backupNextDisabled({
isLoading,
loadError,
}: {
isLoading: boolean;
loadError: string | null;
}): boolean {
return isLoading || loadError !== null;
const INTRO_HOLD_MS = 1400;
/**
* The creation moment should only be sold once per app session. Module-level
* so remounts (e.g. navigating Back and returning to this step) skip the fake
* hold and show the finished state instantly.
*/
let introPlayed = false;
const REVEAL_ANIMATION_CLASS =
"animate-in fade-in duration-700 motion-reduce:animate-none";
const BACKUP_OPTION_CLASS =
"flex min-h-48 w-full flex-col items-start justify-start px-6 py-5 text-left text-foreground";
/** Viewing the key never blocks onboarding — Next is always actionable. */
export function backupNextDisabled(): boolean {
return false;
}
type BackupStepProps = {
direction: OnboardingTransitionDirection;
identityStorage?: IdentityStorage;
onBack: () => void;
onNext: () => void;
onOpenPasswordBackup: () => void;
onShowOptions: () => void;
optionsExpanded: boolean;
returningFromSecurity: boolean;
};
/**
* Onboarding backup step shows the user their freshly created key so they
* can save it somewhere safe. Only shown on the fresh-key path.
* Onboarding identity-key step shows the freshly created key, then opens a
* dark backup-options state. Copy fetches the raw key only after an explicit
* click; password backup opens the separate security flow. Neither method
* blocks Next.
*/
export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
export function BackupStep({
direction,
identityStorage,
onBack,
onNext,
onOpenPasswordBackup,
onShowOptions,
optionsExpanded,
returningFromSecurity,
}: BackupStepProps) {
const reduceMotion = useReducedMotion() ?? false;
const [created, setCreated] = React.useState(introPlayed || reduceMotion);
const [copyState, setCopyState] = React.useState<
"idle" | "copying" | "copied"
>("idle");
const [copyError, setCopyError] = React.useState<string | null>(null);
const [nsec, setNsec] = React.useState<string | null>(null);
const [isLoading, setIsLoading] = React.useState(true);
const [loadError, setLoadError] = React.useState<string | null>(null);
const [isRevealed, setIsRevealed] = React.useState(false);
const cancelledRef = React.useRef(false);
const copiedTimerRef = React.useRef<number | null>(null);
const loadNsec = React.useCallback(async () => {
setIsLoading(true);
setLoadError(null);
try {
const value = await getNsec();
if (!cancelledRef.current) setNsec(value);
} catch (err) {
if (!cancelledRef.current)
setLoadError(
err instanceof Error
? err.message
: "Failed to retrieve private key.",
);
} finally {
if (!cancelledRef.current) setIsLoading(false);
React.useEffect(() => {
if (introPlayed) return;
if (reduceMotion) {
introPlayed = true;
setCreated(true);
return;
}
}, []);
const timer = window.setTimeout(() => {
introPlayed = true;
setCreated(true);
}, INTRO_HOLD_MS);
return () => window.clearTimeout(timer);
}, [reduceMotion]);
React.useEffect(() => {
cancelledRef.current = false;
void loadNsec();
return () => {
// Back-during-fetch: cancel any in-flight setState calls and clear the
// nsec from memory on unmount (backup step is only on the fresh-key path).
cancelledRef.current = true;
setNsec(null);
if (copiedTimerRef.current !== null)
window.clearTimeout(copiedTimerRef.current);
};
}, [loadNsec]);
}, []);
const copyKeyToClipboard = React.useCallback(async () => {
setCopyState("copying");
setCopyError(null);
try {
const value = nsec ?? (await getNsec());
await writeTextToClipboard(value);
if (cancelledRef.current) return;
setCopyState("copied");
if (copiedTimerRef.current !== null)
window.clearTimeout(copiedTimerRef.current);
copiedTimerRef.current = window.setTimeout(() => {
if (!cancelledRef.current) setCopyState("idle");
}, 2000);
} catch (err) {
if (cancelledRef.current) return;
setCopyState("idle");
setCopyError(
err instanceof Error ? err.message : "Failed to retrieve private key.",
);
}
}, [nsec]);
const toggleReveal = React.useCallback(async () => {
if (isRevealed) {
setIsRevealed(false);
return;
}
setCopyError(null);
try {
// The raw key enters the DOM only after this explicit reveal action.
const value = nsec ?? (await getNsec());
if (cancelledRef.current) return;
setNsec(value);
setIsRevealed(true);
} catch (err) {
if (cancelledRef.current) return;
setCopyError(
err instanceof Error ? err.message : "Failed to retrieve private key.",
);
}
}, [isRevealed, nsec]);
// Fixed-length decorative mask (nsec keys are 63 chars) so no key material
// is fetched just to render the blurred row. Bullets are joined with a
// zero-width space: WebKit won't line-break a run of U+2022 without an
// explicit break opportunity, so the masked row would overflow otherwise.
const maskedKey = React.useMemo(
() => Array.from({ length: nsec?.length ?? 63 }, () => "•").join("\u200b"),
[nsec],
);
const storageDescription =
identityStorage === "system-keyring"
? "Buzz keeps your identity key in your system keychain. Your computer may ask for your password when Buzz needs to read the key."
: identityStorage === "local-file"
? "Your system keychain wasnt available, so Buzz keeps your identity key in a private file on this device."
: "Buzz keeps your identity key protected on this device. Make a separate backup in case you lose access.";
const storageTitle =
identityStorage === "system-keyring"
? "Protected by your system keychain"
: identityStorage === "local-file"
? "Stored in private device storage"
: "Protected in private device storage";
const introStorageDescription =
identityStorage === "system-keyring"
? "Buzz keeps your identity key in your system keychain."
: identityStorage === "local-file"
? "Buzz keeps your identity key in a private file on this device because the system keychain wasnt available."
: "Your identity key is protected on this device.";
if (optionsExpanded) {
return (
<OnboardingSlideTransition
className="flex min-h-0 w-full flex-col items-center"
data-testid="onboarding-page-backup-options"
direction={direction}
effect={direction === "forward" ? "mask-reveal-up" : "line-slide"}
transitionKey={`backup-options-${direction}`}
>
<div className="flex w-full max-w-140 shrink-0 flex-col text-center">
<h1 className="text-title font-normal text-foreground">
Backup options
</h1>
<p className="mt-5 text-sm leading-6 text-foreground/75">
Your identity key works like a password for your Buzz account. Keep
a copy somewhere safe. You can create a backup file and lock it with
a password you can remember.
</p>
</div>
<div className="flex w-full max-w-260 flex-1 flex-col justify-center py-10">
<div
className="grid w-full grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3"
data-testid="backup-options"
>
<div
className={cn(BACKUP_OPTION_CLASS, "md:col-span-2 lg:col-span-1")}
data-testid="backup-option-panel"
>
<span className="text-lg font-medium">{storageTitle}</span>
<span className="mt-3 block text-sm leading-6 text-foreground/65">
{storageDescription}
</span>
</div>
<div
className={BACKUP_OPTION_CLASS}
data-testid="backup-option-panel"
>
<span className="text-lg font-medium">
Saved in your password manager
</span>
<span className="mt-3 block text-sm leading-6 text-foreground/65">
Copy your identity key, then save it in a password manager like
1Password.
</span>
<Button
className={cn(
ONBOARDING_SECONDARY_CTA_CLASS,
"mt-5 w-fit gap-2 px-5",
)}
data-testid="backup-copy-key"
disabled={copyState === "copying"}
onClick={() => void copyKeyToClipboard()}
type="button"
variant="ghost"
>
{copyState === "copying" ? (
<Spinner className="h-4 w-4 border-2" />
) : copyState === "copied" ? (
<Check className="h-4 w-4" aria-hidden="true" />
) : (
<Copy className="h-4 w-4" aria-hidden="true" />
)}
{copyState === "copying"
? "Copying…"
: copyState === "copied"
? "Copied to clipboard"
: "Copy to clipboard"}
</Button>
</div>
<div
className={BACKUP_OPTION_CLASS}
data-testid="backup-option-panel"
>
<span className="text-lg font-medium">
Locked in a backup file
</span>
<span className="mt-3 block text-sm leading-6 text-foreground/65">
Create a backup file and choose a password you can remember.
Youll need both to restore your account.
</span>
<Button
className={cn(
ONBOARDING_SECONDARY_CTA_CLASS,
"mt-5 w-fit gap-2 px-5",
)}
data-testid="backup-option-password"
onClick={onOpenPasswordBackup}
type="button"
variant="ghost"
>
<ShieldCheck className="h-5 w-5" aria-hidden="true" />
Create locked backup
</Button>
</div>
</div>
{copyError ? (
<p
className="mt-4 text-center text-sm text-destructive"
data-testid="backup-copy-error"
>
Could not retrieve your private key: {copyError}. You can continue
and find it later in Settings &gt; Profile &gt; Identity.
</p>
) : null}
</div>
</OnboardingSlideTransition>
);
}
return (
<OnboardingSlideTransition
className="flex min-h-0 w-full flex-col items-center"
data-testid="onboarding-page-backup"
direction={direction}
transitionKey={`backup-${direction}`}
effect={returningFromSecurity ? "mask-reveal-down" : "line-slide"}
transitionKey={`backup-${direction}-${returningFromSecurity ? "down" : "line"}`}
>
<div className="flex w-full max-w-[500px] shrink-0 flex-col text-center">
<h1 className="text-title font-normal text-foreground">
Your unique identity key has been created
{/* Plain string concat: cn()'s tailwind-merge misreads the custom
text-title size token as conflicting with text-foreground. */}
<h1
className={`text-title font-normal text-foreground ${REVEAL_ANIMATION_CLASS}`}
key={created ? "created" : "creating"}
>
{created
? "Your unique identity key has been created"
: "Creating your identity key"}
</h1>
<p className="mt-5 text-sm leading-6 text-foreground/80">
This key is stored in your system keychain, but save it some place
safe in case you ever need to restore your account.
</p>
</div>
<div className="flex w-full max-w-[1040px] flex-1 flex-col justify-center py-10">
{isLoading ? (
<div className="flex items-center justify-center gap-2 py-6 text-sm text-foreground/70">
<Spinner className="h-4 w-4 border-2" />
Loading your private key
</div>
) : loadError ? (
<div className="mx-auto max-w-[500px] space-y-3 text-left">
<div
className="flex items-start gap-3 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive"
data-testid="backup-load-error"
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>
Could not retrieve your private key: {loadError}. You can
continue and find it later in Settings &gt; Profile &gt;
Identity.
</span>
</div>
<Button
className="h-8 gap-1.5 text-sm"
data-testid="backup-retry"
onClick={() => void loadNsec()}
size="sm"
{created ? (
<p
className={cn(
"mt-5 text-sm leading-6 text-foreground/80",
REVEAL_ANIMATION_CLASS,
)}
>
{introStorageDescription} You can continue now, or{" "}
<button
className="rounded-sm font-medium underline decoration-foreground/40 underline-offset-4 transition-colors hover:decoration-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
data-testid="backup-options-link"
onClick={onShowOptions}
type="button"
variant="outline"
>
<RefreshCw className="h-3.5 w-3.5" />
Try again
</Button>
</div>
) : nsec ? (
<Card className="w-full px-8 py-6" variant="textured">
<div className="mx-auto w-full max-w-[832px]">
<NsecMaskedDisplay nsec={nsec} variant="bare" />
</div>
</Card>
) : (
<p className="text-center text-sm text-foreground/70">
No key available to back up.
</p>
)}
{nsec ? (
<p className="mx-auto mt-6 flex max-w-[440px] items-start justify-center gap-1.5 text-center text-xs leading-5 text-[var(--buzz-onboarding-backup-ink)]">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
Never share your private key. Anyone with this key can impersonate
you and access everything in your account.
</span>
review backup options
</button>{" "}
for ways to restore your account.
</p>
) : null}
</div>
<OnboardingFooter>
<Button
className={ONBOARDING_PRIMARY_CTA_CLASS}
data-testid="onboarding-next"
disabled={backupNextDisabled({ isLoading, loadError })}
onClick={onNext}
type="button"
{!created ? (
<div
className="flex w-full flex-1 items-center justify-center py-10"
data-testid="backup-intro-logo"
>
Next
</Button>
<FuzzyLogo
ariaLabel="Creating your identity key"
className="w-20! text-foreground"
fuzz
loop
loopRestSeconds={0}
/>
</div>
) : (
<div
className={cn(
"flex w-full max-w-[1040px] flex-1 flex-col justify-center py-10",
REVEAL_ANIMATION_CLASS,
)}
>
<div className="w-full">
<Card className="px-8 py-6" variant="textured">
<div className="mx-auto flex w-full min-w-0 max-w-[832px] items-center gap-4">
<div className="min-w-0 flex-1">
<p
className={cn(
ONBOARDING_KEY_TEXT_CLASS,
isRevealed && nsec
? "select-text"
: "select-none blur-[4px]",
)}
data-testid="backup-key-value"
>
{isRevealed && nsec ? nsec : maskedKey}
</p>
</div>
<Button
aria-label={
isRevealed ? "Hide private key" : "Reveal private key"
}
className="h-10 w-10 shrink-0 text-muted-foreground hover:text-foreground"
data-testid="backup-key-reveal-toggle"
onClick={() => void toggleReveal()}
size="icon"
type="button"
variant="ghost"
>
{isRevealed ? (
<EyeOff className="h-6 w-6" aria-hidden="true" />
) : (
<Eye className="h-6 w-6" aria-hidden="true" />
)}
</Button>
</div>
</Card>
{loadError ? (
{copyError ? (
<p
className="mt-4 text-center text-sm text-destructive"
data-testid="backup-copy-error"
>
Could not retrieve your private key: {copyError}. You can
continue and find it later in Settings &gt; Profile &gt;
Identity.
</p>
) : null}
<p className="mx-auto mt-5 flex max-w-[440px] items-start justify-center gap-1.5 text-center text-xs leading-5 text-[var(--buzz-onboarding-backup-ink)]">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
Never share your private key. Anyone with this key can
impersonate you and access everything in your account.
</span>
</p>
</div>
</div>
)}
{created ? (
<OnboardingFooter className={REVEAL_ANIMATION_CLASS}>
<Button
className="h-9 rounded-full px-5 text-muted-foreground hover:text-accent-foreground"
data-testid="backup-skip"
className={ONBOARDING_PRIMARY_CTA_CLASS}
data-testid="onboarding-next"
disabled={backupNextDisabled()}
onClick={onNext}
type="button"
>
Next
</Button>
<Button
className={ONBOARDING_SECONDARY_CTA_CLASS}
data-testid="onboarding-back"
onClick={onBack}
type="button"
variant="ghost"
>
Skip for now
Back
</Button>
) : null}
<Button
className="h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
data-testid="onboarding-back"
onClick={onBack}
type="button"
variant="ghost"
>
Back
</Button>
</OnboardingFooter>
</OnboardingFooter>
) : null}
</OnboardingSlideTransition>
);
}
@@ -0,0 +1,745 @@
import { Check, CircleHelp, Eye, EyeOff, FileKey2, FileUp } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import * as React from "react";
import { createPortal } from "react-dom";
import {
getNsec,
verifyNcryptsecBackup,
type BackupVerification,
} from "@/shared/api/tauriIdentity";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Card } from "@/shared/ui/card";
import { Input } from "@/shared/ui/input";
import { PubKey } from "@/shared/ui/PubKey";
import { Spinner } from "@/shared/ui/spinner";
import {
ONBOARDING_SECURITY_PRIMARY_CTA_CLASS,
ONBOARDING_SECONDARY_CTA_CLASS,
} from "./OnboardingChrome";
type BackupTestStage = "drop" | "password" | "success";
/**
* Durable progress through the test flow. Owned by the host so navigating
* away (e.g. onboarding Back) and returning doesn't force the user to
* re-drop the file. The password attempt is deliberately NOT part of this
* state it lives only in short-lived component state and is cleared the
* moment it's submitted or the component unmounts.
*/
export type BackupTestProgress = {
stage: BackupTestStage;
/** Name of the accepted file once the drop check passed. */
fileName: string | null;
/** Contents of the accepted file, pending or past verification. */
ncryptsec: string | null;
/** The Rust-verified public identity once decryption succeeded. */
result: BackupVerification | null;
};
export const initialBackupTestProgress: BackupTestProgress = {
stage: "drop",
fileName: null,
ncryptsec: null,
result: null,
};
type BackupTestFlowProps = {
/** "spotlight" is the onboarding treatment; "boxed" fits settings cards. */
variant?: "spotlight" | "boxed";
/**
* When supplied, only this exact just-created file is accepted the
* onboarding ceremony proves the user saved *that* backup. Without it the
* flow is a general-purpose tester for any key backup file.
*/
expectedNcryptsec?: string;
/** Re-open the native save dialog for another copy of the backup file. */
onSaveCopy?: () => void;
isSaving?: boolean;
saveError?: string | null;
/** Optional onboarding footer target for the verification CTA. */
verifyButtonPortal?: HTMLElement | null;
/** Host-owned progress so it survives this component unmounting. */
progress: BackupTestProgress;
onProgressChange: React.Dispatch<React.SetStateAction<BackupTestProgress>>;
/** Fired once when the user completes the test successfully. */
onVerified?: () => void;
};
const BURST_EMOJIS = ["🎉", "✨", "🐝", "🍯", "🔑", "💛"] as const;
const BURST_PARTICLE_COUNT = 18;
const VERIFICATION_CONNECTOR_DOTS = [
"verification-dot-1",
"verification-dot-2",
"verification-dot-3",
"verification-dot-4",
] as const;
const VERIFICATION_DOT_ANIMATION = {
opacity: [0.35, 1, 0.35],
scale: [0.85, 1.25, 0.85],
};
const VERIFICATION_DOT_TRANSITION = {
duration: 0.7,
ease: "easeInOut" as const,
repeat: Number.POSITIVE_INFINITY,
repeatDelay: 1.2,
};
const PRIVATE_KEY_MASK = Array.from({ length: 63 }, () => "•").join("\u200b");
type BurstParticle = {
id: number;
x: number;
y: number;
emoji: string;
delay: number;
scale: number;
rotate: number;
};
/**
* One-shot radial emoji burst behind the success badge. Purely decorative
* skipped entirely under reduced motion.
*/
function SuccessBurst() {
const particles = React.useMemo<BurstParticle[]>(
() =>
Array.from({ length: BURST_PARTICLE_COUNT }, (_, i) => {
const angle =
(i / BURST_PARTICLE_COUNT) * Math.PI * 2 + Math.random() * 0.5;
const distance = 70 + Math.random() * 80;
return {
id: i,
x: Math.cos(angle) * distance,
y: Math.sin(angle) * distance,
emoji: BURST_EMOJIS[i % BURST_EMOJIS.length],
delay: Math.random() * 0.18,
scale: 0.8 + Math.random() * 0.7,
rotate: -120 + Math.random() * 240,
};
}),
[],
);
return (
<div
aria-hidden
className="pointer-events-none absolute inset-0 flex items-center justify-center overflow-visible"
>
{particles.map((particle) => (
<motion.span
animate={{
x: particle.x,
y: particle.y,
opacity: 0,
scale: particle.scale,
rotate: particle.rotate,
}}
className="absolute text-xl"
initial={{ x: 0, y: 0, opacity: 1, scale: 0.3, rotate: 0 }}
key={particle.id}
transition={{
duration: 0.9,
delay: particle.delay,
ease: "easeOut",
}}
>
{particle.emoji}
</motion.span>
))}
</div>
);
}
function VerificationConnector({
delayOffset,
reduceMotion,
}: {
delayOffset: number;
reduceMotion: boolean;
}) {
return (
<div
aria-hidden
className="my-5 flex h-14 flex-col items-center justify-between py-1"
>
{VERIFICATION_CONNECTOR_DOTS.map((dot, index) => (
<motion.span
animate={reduceMotion ? undefined : VERIFICATION_DOT_ANIMATION}
className="block size-1.5 rounded-full bg-foreground/65"
initial={reduceMotion ? false : { opacity: 0.35, scale: 0.85 }}
key={dot}
transition={
reduceMotion
? undefined
: {
...VERIFICATION_DOT_TRANSITION,
delay:
(VERIFICATION_CONNECTOR_DOTS.length -
1 -
index +
delayOffset) *
0.16,
}
}
/>
))}
</div>
);
}
/**
* "Test your backup" flow: the user drops a backup file onto a large
* dropzone, then enters its password. Verification is a real NIP-49 decrypt
* in Rust the submitted password is cleared immediately after the result
* and only the derived public identity ever comes back.
*/
export function BackupTestFlow({
variant = "spotlight",
expectedNcryptsec,
onSaveCopy,
isSaving = false,
saveError,
verifyButtonPortal,
progress,
onProgressChange,
onVerified,
}: BackupTestFlowProps) {
const reduceMotion = useReducedMotion() ?? false;
const { stage, fileName, ncryptsec, result } = progress;
// True while a file drag is anywhere over the window — the drop overlay
// takes over the host surface only for the duration of the drag.
const [isWindowDragging, setIsWindowDragging] = React.useState(false);
const dragDepthRef = React.useRef(0);
React.useEffect(() => {
// dragenter/dragleave fire per nested element, so track depth to know
// when the drag has actually left the window.
const handleDragEnter = (event: DragEvent) => {
if (!event.dataTransfer?.types.includes("Files")) return;
dragDepthRef.current += 1;
setIsWindowDragging(true);
};
const handleDragLeave = () => {
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) setIsWindowDragging(false);
};
const handleDragEnd = () => {
dragDepthRef.current = 0;
setIsWindowDragging(false);
};
window.addEventListener("dragenter", handleDragEnter);
window.addEventListener("dragleave", handleDragLeave);
window.addEventListener("drop", handleDragEnd);
window.addEventListener("dragend", handleDragEnd);
return () => {
window.removeEventListener("dragenter", handleDragEnter);
window.removeEventListener("dragleave", handleDragLeave);
window.removeEventListener("drop", handleDragEnd);
window.removeEventListener("dragend", handleDragEnd);
};
}, []);
// The password attempt is component-local, never host state: it is cleared
// when verification is submitted and when this component unmounts.
const [attempt, setAttempt] = React.useState("");
const [error, setError] = React.useState<string | null>(null);
const [isVerifying, setIsVerifying] = React.useState(false);
const [isRevealed, setIsRevealed] = React.useState(false);
const [successNsec, setSuccessNsec] = React.useState<string | null>(null);
const [isSuccessNsecRevealed, setIsSuccessNsecRevealed] =
React.useState(false);
const [isLoadingSuccessNsec, setIsLoadingSuccessNsec] = React.useState(false);
const [successNsecError, setSuccessNsecError] = React.useState<string | null>(
null,
);
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
const passwordInputRef = React.useRef<HTMLInputElement | null>(null);
const mountedRef = React.useRef(true);
// Opaque correlation id so a stale in-flight verification can't commit
// after "Use a different file" or unmount.
const requestRef = React.useRef(0);
React.useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
requestRef.current += 1;
setAttempt("");
};
}, []);
React.useEffect(() => {
if (stage === "password") passwordInputRef.current?.focus();
}, [stage]);
const handleFile = React.useCallback(
async (file: File) => {
let text: string;
try {
text = (await file.text()).trim();
} catch {
if (mountedRef.current) setError("Could not read that file.");
return;
}
if (!mountedRef.current) return;
if (!text.toLowerCase().startsWith("ncryptsec1")) {
setError(
expectedNcryptsec
? "That doesn't look like your key backup. Choose the file you just downloaded."
: "That doesn't look like a key backup file.",
);
return;
}
if (expectedNcryptsec && text !== expectedNcryptsec.trim()) {
setError("That's a key backup, but not the one you just downloaded.");
return;
}
setError(null);
setAttempt("");
onProgressChange({
stage: "password",
fileName: file.name,
ncryptsec: text,
result: null,
});
},
[expectedNcryptsec, onProgressChange],
);
const handleVerify = React.useCallback(async () => {
if (!ncryptsec || !attempt || isVerifying) return;
const password = attempt;
const requestId = ++requestRef.current;
setIsVerifying(true);
setError(null);
setIsRevealed(false);
// Clear the attempt the moment it's handed to Rust — success or failure,
// the typed password never lingers in the field.
setAttempt("");
try {
const verified = await verifyNcryptsecBackup(ncryptsec, password);
if (!mountedRef.current || requestId !== requestRef.current) return;
onProgressChange((prev) => ({
...prev,
stage: "success",
result: verified,
}));
onVerified?.();
} catch (err) {
if (mountedRef.current && requestId === requestRef.current)
setError(
err instanceof Error ? err.message : "Could not verify this backup.",
);
} finally {
if (mountedRef.current && requestId === requestRef.current)
setIsVerifying(false);
}
}, [attempt, isVerifying, ncryptsec, onProgressChange, onVerified]);
const toggleSuccessNsec = React.useCallback(async () => {
if (isSuccessNsecRevealed) {
setIsSuccessNsecRevealed(false);
return;
}
if (successNsec) {
setIsSuccessNsecRevealed(true);
return;
}
setIsLoadingSuccessNsec(true);
setSuccessNsecError(null);
try {
const value = await getNsec();
if (!mountedRef.current) return;
setSuccessNsec(value);
setIsSuccessNsecRevealed(true);
} catch (err) {
if (!mountedRef.current) return;
setSuccessNsecError(
err instanceof Error ? err.message : "Could not retrieve your key.",
);
} finally {
if (mountedRef.current) setIsLoadingSuccessNsec(false);
}
}, [isSuccessNsecRevealed, successNsec]);
const isSpotlight = variant === "spotlight";
if (stage === "success" && result) {
// The onboarding ceremony pins the exact file, so a success there is by
// construction the current identity — celebrate and move on. The general
// tester reports which identity the backup unlocks.
const isCeremony = Boolean(expectedNcryptsec);
return (
<div
className="relative flex flex-col items-center gap-4 py-4 text-center"
data-testid="backup-test-success"
>
{reduceMotion ? null : <SuccessBurst />}
<motion.div
animate={{ scale: 1, opacity: 1 }}
className="flex h-16 w-16 items-center justify-center rounded-full bg-primary text-primary-foreground"
initial={reduceMotion ? false : { scale: 0, opacity: 0 }}
transition={
reduceMotion
? { duration: 0 }
: { type: "spring", stiffness: 380, damping: 18 }
}
>
<Check aria-hidden="true" className="h-8 w-8" strokeWidth={3} />
</motion.div>
<motion.div
animate={{ opacity: 1, y: 0 }}
initial={reduceMotion ? false : { opacity: 0, y: 8 }}
transition={
reduceMotion ? { duration: 0 } : { delay: 0.15, duration: 0.35 }
}
>
{isCeremony ? (
<div className="w-full max-w-140">
<p className="text-lg font-medium text-foreground">
Your backup works!
</p>
<p className="mt-1.5 text-sm leading-6 text-muted-foreground">
File and password verified. Keep them both somewhere safe
that&apos;s all you need to restore your identity.
</p>
<div className="mx-auto mt-4 flex max-w-110 min-w-0 items-center gap-2 text-left">
<p
className={cn(
"min-w-0 flex-1 break-all font-mono text-base leading-6 wrap-anywhere",
isSuccessNsecRevealed
? "select-text text-foreground"
: "select-none blur-[2px] text-muted-foreground",
)}
data-testid="backup-success-nsec-value"
>
{isSuccessNsecRevealed && successNsec
? successNsec
: PRIVATE_KEY_MASK}
</p>
<Button
aria-label={
isSuccessNsecRevealed
? "Hide unlocked private key"
: "Reveal unlocked private key"
}
className="size-9 shrink-0 text-muted-foreground hover:text-foreground"
data-testid="backup-success-nsec-toggle"
disabled={isLoadingSuccessNsec}
onClick={() => void toggleSuccessNsec()}
size="icon"
type="button"
variant="ghost"
>
{isLoadingSuccessNsec ? (
<Spinner className="size-4 border-2" />
) : isSuccessNsecRevealed ? (
<EyeOff aria-hidden="true" className="size-4" />
) : (
<Eye aria-hidden="true" className="size-4" />
)}
</Button>
</div>
{successNsecError ? (
<p
className="mt-2 text-xs text-destructive"
data-testid="backup-success-nsec-error"
role="alert"
>
{successNsecError}
</p>
) : null}
</div>
) : (
<>
<p className="text-lg font-medium text-foreground">
This backup works
</p>
<p className="mt-1.5 text-sm leading-6 text-muted-foreground">
{result.matchesCurrentIdentity
? "It restores your current Buzz identity."
: "It restores a different identity than the one signed in here."}
</p>
<div className="mt-3 flex justify-center">
<PubKey
pubkey={result.pubkey}
testId="backup-test-npub"
variant="full"
/>
</div>
</>
)}
</motion.div>
{isCeremony ? null : (
<Button
className="h-8 rounded-full px-4 text-xs text-muted-foreground hover:text-foreground"
data-testid="backup-test-another"
onClick={() => {
setError(null);
onProgressChange(initialBackupTestProgress);
}}
type="button"
variant="ghost"
>
Test another backup
</Button>
)}
</div>
);
}
return (
<div
className={cn(
"mx-auto w-full space-y-4",
isSpotlight ? "max-w-140" : "max-w-125",
)}
data-testid="backup-test-flow"
>
{stage === "drop" ? (
<motion.div
animate={{ opacity: 1, y: 0 }}
className="relative space-y-4"
initial={reduceMotion ? false : { opacity: 0, y: 10 }}
key="drop"
transition={{ duration: reduceMotion ? 0 : 0.3, ease: "easeOut" }}
>
<input
accept=".ncryptsec,text/plain"
className="sr-only"
data-testid="backup-test-file-input"
onChange={(event) => {
const file = event.target.files?.[0];
// Allow re-selecting the same file after an error.
event.target.value = "";
if (file) void handleFile(file);
}}
ref={fileInputRef}
tabIndex={-1}
type="file"
/>
<Button
className={cn(
"mx-auto",
isSpotlight
? ONBOARDING_SECURITY_PRIMARY_CTA_CLASS
: "h-9 px-6 text-primary-foreground",
)}
data-testid="backup-test-dropzone"
onClick={() => fileInputRef.current?.click()}
type="button"
>
<span className="font-medium text-sm">Select your backup file</span>
</Button>
{isWindowDragging ? (
/*
* Composer-style takeover: fills the nearest positioned host
* surface (the onboarding card / the settings backup row) and is
* itself the drop target, so anywhere on that surface accepts
* the file.
*/
// biome-ignore lint/a11y/noStaticElementInteractions: pointer-only drop target; the select button is the keyboard-accessible path
<div
className="absolute inset-2 z-10 mt-0! flex items-center justify-center bg-primary/10 backdrop-blur-sm"
data-testid="backup-test-drop-overlay"
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
const file = event.dataTransfer.files?.[0];
if (file) void handleFile(file);
}}
>
<Card
className="flex-row items-center gap-2 px-10 py-8 text-sm font-semibold text-foreground"
textureSize="compact"
textureTone="dark"
variant="textured"
>
<FileUp aria-hidden="true" className="size-4" />
<span>Drop your backup file here</span>
</Card>
</div>
) : null}
{error ? (
<p
className="text-center text-sm text-destructive"
data-testid="backup-test-error"
role="alert"
>
{error}
</p>
) : null}
{onSaveCopy ? (
<div className="flex flex-col items-center gap-2">
<Button
className={cn(
"gap-1.5",
isSpotlight
? ONBOARDING_SECONDARY_CTA_CLASS
: "h-9 rounded-full bg-foreground/10 px-6 text-sm hover:bg-foreground/15",
)}
data-testid="encrypted-backup-save-copy"
disabled={isSaving}
onClick={onSaveCopy}
type="button"
variant="ghost"
>
{isSaving ? <Spinner className="h-4 w-4 border-2" /> : null}
Re-download backup
</Button>
</div>
) : null}
{saveError ? (
<p className="text-center text-sm text-destructive">{saveError}</p>
) : null}
</motion.div>
) : (
<motion.div
animate={{ opacity: 1, y: 0 }}
className="space-y-4"
initial={reduceMotion ? false : { opacity: 0, y: 10 }}
key="password"
transition={{ duration: reduceMotion ? 0 : 0.3, ease: "easeOut" }}
>
{(() => {
const fileRow = (
<div
className="flex max-w-full items-center gap-3 rounded-2xl border border-foreground/15 bg-foreground/10 px-4 py-3 text-foreground shadow-sm animate-in fade-in slide-in-from-bottom-1 duration-300 motion-reduce:animate-none"
data-testid="backup-test-file-accepted"
>
<span className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-foreground/10">
<FileKey2
aria-hidden="true"
className="size-5 text-foreground/80"
/>
</span>
<span className="max-w-70 truncate font-mono text-sm">
{fileName}
</span>
</div>
);
const passwordField = (
<div className="relative w-full">
<Input
aria-label="Backup password"
autoComplete="off"
className={cn(
"font-mono",
isSpotlight
? "h-14 rounded-2xl border-black/20 bg-white px-14 text-center text-lg text-black/80 shadow-none placeholder:text-black/55 focus-visible:ring-black/35"
: "h-10 bg-background pr-10",
)}
data-testid="backup-test-password"
disabled={isVerifying}
onChange={(event) => setAttempt(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void handleVerify();
}
}}
placeholder="Your backup password"
ref={passwordInputRef}
type={isRevealed ? "text" : "password"}
value={attempt}
/>
<Button
aria-label={isRevealed ? "Hide password" : "Reveal password"}
className={cn(
"absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2 text-muted-foreground hover:text-foreground",
isSpotlight &&
"text-black/55 hover:bg-black/5 hover:text-black/80",
)}
data-testid="backup-test-password-reveal-toggle"
disabled={isVerifying}
onClick={() => setIsRevealed((revealed) => !revealed)}
size="icon"
type="button"
variant="ghost"
>
{isRevealed ? (
<EyeOff aria-hidden="true" className="h-4 w-4" />
) : (
<Eye aria-hidden="true" className="h-4 w-4" />
)}
</Button>
{error ? (
<p
className="absolute left-1 top-full mt-1 text-xs text-destructive animate-in fade-in duration-200 motion-reduce:animate-none"
data-testid="backup-test-error"
role="alert"
>
{error}
</p>
) : null}
</div>
);
if (!isSpotlight) {
return (
<>
{fileRow}
<p className="text-center text-sm leading-6 text-muted-foreground">
Enter the password to prove you can unlock this backup.
</p>
{passwordField}
</>
);
}
return (
<div className="flex w-full flex-col items-center">
<CircleHelp
aria-hidden="true"
className="size-10 text-foreground/85"
/>
<VerificationConnector
delayOffset={VERIFICATION_CONNECTOR_DOTS.length}
reduceMotion={reduceMotion}
/>
{passwordField}
<VerificationConnector
delayOffset={0}
reduceMotion={reduceMotion}
/>
{fileRow}
</div>
);
})()}
{(() => {
const verifyButton = (
<Button
className={cn(
"font-medium",
isSpotlight
? ONBOARDING_SECURITY_PRIMARY_CTA_CLASS
: "h-9 px-6 text-sm text-primary-foreground",
)}
data-testid="backup-test-verify"
disabled={!attempt || isVerifying}
onClick={() => void handleVerify()}
type="button"
>
{isVerifying ? (
<>
<Spinner className="h-4 w-4 border-2" />
Checking
</>
) : (
"Verify backup"
)}
</Button>
);
if (verifyButtonPortal === undefined) {
return (
<div className="flex justify-center pt-2">{verifyButton}</div>
);
}
return verifyButtonPortal
? createPortal(verifyButton, verifyButtonPortal)
: null;
})()}
</motion.div>
)}
</div>
);
}
@@ -0,0 +1,139 @@
import { motion, useReducedMotion } from "motion/react";
import * as React from "react";
import { Button } from "@/shared/ui/button";
import {
ONBOARDING_SECURITY_PRIMARY_CTA_CLASS,
ONBOARDING_SECONDARY_CTA_CLASS,
} from "./OnboardingChrome";
import { OnboardingFooter } from "./OnboardingFooter";
import {
type OnboardingTransitionDirection,
OnboardingSlideTransition,
} from "./OnboardingSlideTransition";
import {
type EncryptedBackupSession,
EncryptedBackupCreator,
} from "./EncryptedBackupCreator";
type DownloadKeyStepProps = {
direction: OnboardingTransitionDirection;
/** Backup state owned by the parent flow across the creation and test views. */
session: EncryptedBackupSession;
onBack: () => void;
};
/**
* Password-backup security subview within the identity-key onboarding step.
* The raw key never enters this component: Rust builds the NIP-49 payload
* locally and the native save dialog produces the user-owned file.
*/
export function DownloadKeyStep({
direction,
session,
onBack,
}: DownloadKeyStepProps) {
const reduceMotion = useReducedMotion() ?? false;
// Once the encrypted payload is saved, the creator advances to its guided
// backup test while this surface keeps its own navigation.
const hasCreated = session.created;
const hasVerifiedBackup = session.verified;
const hasSelectedBackup = session.test.stage === "password";
const [primaryActionSlot, setPrimaryActionSlot] =
React.useState<HTMLElement | null>(null);
return (
<OnboardingSlideTransition
className="flex min-h-0 w-full flex-col items-center"
data-testid="onboarding-page-download"
direction={direction}
transitionKey={`download-${direction}`}
>
<motion.div
animate={{ opacity: 1, y: 0 }}
className="flex w-full max-w-[500px] shrink-0 flex-col text-center"
initial={reduceMotion ? false : { opacity: 0, y: 10 }}
key={
hasVerifiedBackup
? "success-heading"
: hasCreated
? "test-heading"
: "password-heading"
}
transition={{ duration: reduceMotion ? 0 : 0.3, ease: "easeOut" }}
>
{/* Plain string concat: cn()'s tailwind-merge misreads the custom
text-title size token as conflicting with text-foreground. */}
<h1 className="text-title font-normal text-foreground">
{hasVerifiedBackup
? "Your backup is verified"
: hasSelectedBackup
? "Thats your backup file"
: hasCreated
? "Optionally, test your backup"
: "Backup your key with a password"}
</h1>
<p className="mt-5 text-sm leading-6 text-foreground/80">
{hasVerifiedBackup
? "Your file and password can restore your identity."
: hasSelectedBackup
? "Now enter your password to prove you can unlock it."
: hasCreated
? "Learn how your backup works. Drop the file you just saved and unlock it with your password."
: "Keep the downloaded file private — you need both it and your password to restore your identity. Save the backup password somewhere safe; Buzz cannot reset it if lost."}
</p>
</motion.div>
<div className="flex w-full max-w-[1040px] flex-1 flex-col justify-center py-10">
<div className="w-full">
<motion.div
animate={{ opacity: 1, y: 0 }}
initial={reduceMotion ? false : { opacity: 0, y: 12 }}
key={hasCreated ? "test-panel" : "password-panel"}
transition={{
delay: reduceMotion ? 0 : 0.12,
duration: reduceMotion ? 0 : 0.4,
ease: "easeOut",
}}
>
<div
className="mx-auto w-full max-w-140 px-6 py-5"
data-testid="backup-password-panel"
>
<EncryptedBackupCreator
createButtonClassName={ONBOARDING_SECURITY_PRIMARY_CTA_CLASS}
createButtonPortal={primaryActionSlot}
session={session}
variant="spotlight"
verifyButtonPortal={primaryActionSlot}
/>
</div>
</motion.div>
</div>
</div>
<OnboardingFooter>
<div
className="flex justify-center"
data-testid={
hasCreated ? "onboarding-verify-slot" : "onboarding-create-slot"
}
ref={setPrimaryActionSlot}
/>
<Button
className={
hasVerifiedBackup
? ONBOARDING_SECURITY_PRIMARY_CTA_CLASS
: ONBOARDING_SECONDARY_CTA_CLASS
}
data-testid="onboarding-back"
onClick={onBack}
type="button"
variant="ghost"
>
{hasVerifiedBackup ? "Finish" : hasCreated ? "Skip for now" : "Back"}
</Button>
</OnboardingFooter>
</OnboardingSlideTransition>
);
}
@@ -0,0 +1,885 @@
import { AlertTriangle, Eye, EyeOff, RefreshCw } from "lucide-react";
import * as React from "react";
import { createPortal } from "react-dom";
import {
createNcryptsecBackup,
generateBackupPassphrase,
saveNcryptsecCopy,
} from "@/shared/api/tauriIdentity";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover";
import { Spinner } from "@/shared/ui/spinner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/ui/alert-dialog";
import {
downloadDisabled,
passphraseIssue,
pendingEncryptPassphrase,
encryptedBackupReducer,
initialEncryptedBackupState,
MIN_PASSPHRASE_LEN,
type EncryptedBackupEvent,
type EncryptedBackupState,
} from "../lib/encryptedBackup";
import {
type BackupTestProgress,
BackupTestFlow,
initialBackupTestProgress,
} from "./BackupTestFlow";
import { BackupPasswordTimeline } from "./BackupPasswordTimeline";
import {
ONBOARDING_SECURITY_PRIMARY_CTA_CLASS,
ONBOARDING_SECONDARY_CTA_CLASS,
} from "./OnboardingChrome";
/** Word-count bounds mirroring `key_backup.rs` (Rust clamps regardless). */
const MIN_GENERATED_WORDS = 3;
const MAX_GENERATED_WORDS = 10;
const DEFAULT_GENERATED_WORDS = 3;
const SEPARATOR_OPTIONS = [
{ label: "Spaces", value: " " },
{ label: "Hyphens", value: "-" },
{ label: "Periods", value: "." },
{ label: "Commas", value: "," },
] as const;
const DEFAULT_SEPARATOR = SEPARATOR_OPTIONS[0].value;
/**
* Pause after the last keystroke before the background KDF starts, so typing
* past the minimum length doesn't launch an encryption per character.
*/
const ENCRYPT_DEBOUNCE_MS = 400;
const PENDING_TICKER_MESSAGES = [
"Downloading once finished",
"Encrypting your password",
"Just a bit longer...",
] as const;
/** How long each ticker message holds before sliding to the next. */
const PENDING_TICKER_INTERVAL_MS = 2500;
/** Matches the `duration-300` slide transition on the ticker column. */
const PENDING_TICKER_SLIDE_MS = 300;
/**
* Vertical ticker for the queued-download button label cycles through the
* pending messages by sliding a stacked column inside a one-line viewport.
* The column ends with a clone of the first message, so the wrap-around
* slides up from the bottom like every other step; once the clone settles,
* the column snaps (transition disabled) back to the real first row. All
* lines render at all times, so the button keeps the width of the longest
* message instead of resizing on each swap.
*/
function PendingDownloadTicker() {
// Index into the rendered column (messages + trailing clone of the first).
const [position, setPosition] = React.useState(0);
const [snap, setSnap] = React.useState(false);
React.useEffect(() => {
const timer = window.setInterval(
() => setPosition((current) => current + 1),
PENDING_TICKER_INTERVAL_MS,
);
return () => window.clearInterval(timer);
}, []);
// The clone is visually identical to the first message: once its slide-in
// finishes, jump back to the real first row without animating.
React.useEffect(() => {
if (position !== PENDING_TICKER_MESSAGES.length) return;
const timer = window.setTimeout(() => {
setSnap(true);
setPosition(0);
}, PENDING_TICKER_SLIDE_MS);
return () => window.clearTimeout(timer);
}, [position]);
// Re-enable the transition one frame after the snap has painted.
React.useEffect(() => {
if (!snap) return;
const raf = window.requestAnimationFrame(() => setSnap(false));
return () => window.cancelAnimationFrame(raf);
}, [snap]);
// The clone row duplicates the first message's text, so it carries its own
// stable key.
const column = [
...PENDING_TICKER_MESSAGES.map((message) => ({ key: message, message })),
{ key: "wrap-clone", message: PENDING_TICKER_MESSAGES[0] },
];
return (
<span
aria-live="polite"
className="relative block h-5 overflow-hidden"
data-testid="encrypted-backup-pending-ticker"
>
<span
className={cn(
"block ease-out",
snap
? "transition-none"
: "transition-transform duration-300 motion-reduce:transition-none",
)}
style={{ transform: `translateY(-${position * 1.25}rem)` }}
>
{column.map((row) => (
<span
className="flex h-5 items-center justify-center whitespace-nowrap"
key={row.key}
>
{row.message}
</span>
))}
</span>
</span>
);
}
/**
* Everything about an in-progress backup that must survive this component
* unmounting: the reducer state (short-lived passphrase + encrypted blob), whether the
* backup test passed, where the file was saved, the save-once guard, and the
* test-flow progress. Hosts that need the state to outlive the creator (the
* onboarding flow, where Back unmounts the step) call
* `useEncryptedBackupSession` at a longer-lived level and pass it down;
* otherwise the creator owns a private session internally.
*/
export type EncryptedBackupSession = {
state: EncryptedBackupState;
dispatch: React.Dispatch<EncryptedBackupEvent>;
/**
* True once the encrypted payload has been committed AND saved to disk.
* Derived so hosts (e.g. DownloadKeyStep) can branch on it without touching
* the blob itself keeping them outside the ncryptsec confinement scan.
*/
created: boolean;
/** True once the user has passed the backup test. */
verified: boolean;
setVerified: React.Dispatch<React.SetStateAction<boolean>>;
savedPath: string | null;
setSavedPath: React.Dispatch<React.SetStateAction<string | null>>;
/** The committed blob a save was already kicked off for (save-once guard). */
savedForRef: React.MutableRefObject<string | null>;
test: BackupTestProgress;
setTest: React.Dispatch<React.SetStateAction<BackupTestProgress>>;
};
/** Host-side state for `EncryptedBackupCreator` — see `EncryptedBackupSession`. */
export function useEncryptedBackupSession(): EncryptedBackupSession {
const [state, dispatch] = React.useReducer(
encryptedBackupReducer,
initialEncryptedBackupState,
);
const [verified, setVerified] = React.useState(false);
const [savedPath, setSavedPath] = React.useState<string | null>(null);
const savedForRef = React.useRef<string | null>(null);
const [test, setTest] = React.useState<BackupTestProgress>(
initialBackupTestProgress,
);
return React.useMemo(
() => ({
state,
dispatch,
created: state.ncryptsec !== null && savedPath !== null,
verified,
setVerified,
savedPath,
setSavedPath,
savedForRef,
test,
setTest,
}),
[state, verified, savedPath, test],
);
}
/**
* Return to a secure saved-password placeholder. The encrypted blob survives
* for instant re-download, while no password or test attempt is retained.
*/
export function backupSessionToPasswordEntry(
session: EncryptedBackupSession,
): void {
session.dispatch({ type: "back-to-password" });
session.setVerified(false);
session.setSavedPath(null);
session.setTest(initialBackupTestProgress);
}
/** Discard all backup-creation and verification progress. */
export function resetEncryptedBackupSession(
session: EncryptedBackupSession,
): void {
session.dispatch({ type: "start-new-backup" });
session.setVerified(false);
session.setSavedPath(null);
session.savedForRef.current = null;
session.setTest(initialBackupTestProgress);
}
type EncryptedBackupCreatorProps = {
/** "spotlight" is the onboarding treatment; "boxed" fits settings cards. */
variant?: "spotlight" | "boxed";
/**
* When set, the "Download" button is portaled into this element instead of
* rendering inline.
*/
createButtonPortal?: HTMLElement | null;
/** Optional onboarding footer target for the guided-test verification CTA. */
verifyButtonPortal?: HTMLElement | null;
/** Extra classes for the "Download" button. */
createButtonClassName?: string;
/**
* Host-owned session so the backup state survives this component
* unmounting (onboarding Back navigation). Omitted = private session.
*/
session?: EncryptedBackupSession;
/** Fired once the encrypted payload has been created (before saving). */
onCreated?: () => void;
/** Fired only after the encrypted key file has been saved successfully. */
onSaved?: (path: string) => void;
/** Whether creation continues into onboarding's guided test ceremony. */
guidedTest?: boolean;
/** Fired once when the user completes the backup test successfully. */
onVerified?: () => void;
};
/**
* 1Password-style memorable-password generator popover with word-count and
* separator fields, anchored to a refresh icon inset in the password field
* (the anchor assumes a `relative` parent). The first click opens the
* popover and generates; further clicks on the icon re-roll while the
* popover stays open only click-outside or Esc closes it. There is no
* candidate preview: every generation writes the passphrase straight into
* the parent's password field via `onGenerated`.
*/
function PassphraseGeneratorPopover({
disabled = false,
onRequestGenerate,
onGenerated,
securityTheme = false,
}: {
disabled?: boolean;
onRequestGenerate?: () => void;
onGenerated: (value: string) => void;
securityTheme?: boolean;
}) {
const [open, setOpen] = React.useState(false);
const [words, setWords] = React.useState(DEFAULT_GENERATED_WORDS);
const [separator, setSeparator] = React.useState<string>(DEFAULT_SEPARATOR);
const [error, setError] = React.useState<string | null>(null);
const anchorRef = React.useRef<HTMLButtonElement | null>(null);
const mountedRef = React.useRef(true);
// Read via a ref so `generate` stays reference-stable even though parents
// pass an inline `onGenerated`. Otherwise each generated password would
// re-render the parent, rebuild `generate`, and re-fire the open/controls
// effect below — an infinite generate loop while the popover is open.
const onGeneratedRef = React.useRef(onGenerated);
React.useEffect(() => {
onGeneratedRef.current = onGenerated;
}, [onGenerated]);
React.useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const generate = React.useCallback(async (wordCount: number, sep: string) => {
setError(null);
try {
const passphrase = await generateBackupPassphrase({
words: wordCount,
separator: sep,
});
if (mountedRef.current) onGeneratedRef.current(passphrase);
} catch (err) {
if (!mountedRef.current) return;
setError(
err instanceof Error ? err.message : "Failed to generate a password.",
);
}
}, []);
// Fill the password field on every open and whenever a control changes.
React.useEffect(() => {
if (open) void generate(words, separator);
}, [open, words, separator, generate]);
return (
<Popover onOpenChange={setOpen} open={open}>
{/* Anchor (not Trigger): Radix triggers toggle on click, but repeat
clicks here must generate a fresh password while the popover stays
open. Only click-outside or Esc closes it. */}
<PopoverAnchor asChild>
<Button
aria-label="Generate a password"
className={cn(
"absolute right-9 top-1/2 h-8 w-8 -translate-y-1/2 text-muted-foreground hover:text-foreground",
securityTheme &&
"text-black/55 hover:bg-black/5 hover:text-black/80",
)}
data-testid="backup-passphrase-generate"
disabled={disabled}
onClick={() => {
// The open effect below generates the first password; later
// clicks re-roll with the current controls.
if (onRequestGenerate) {
onRequestGenerate();
return;
}
if (!open) setOpen(true);
else void generate(words, separator);
}}
ref={anchorRef}
size="icon"
type="button"
variant="ghost"
>
<RefreshCw className="h-4 w-4" aria-hidden="true" />
</Button>
</PopoverAnchor>
<PopoverContent
align="end"
className="w-72 space-y-3 text-foreground"
onInteractOutside={(event) => {
// Clicking the anchor icon is "outside" the content — keep the
// popover open so that click re-rolls instead of closing.
if (
event.target instanceof Node &&
anchorRef.current?.contains(event.target)
) {
event.preventDefault();
}
}}
onOpenAutoFocus={(event) => event.preventDefault()}
>
<div className="flex items-center justify-between gap-4">
<label
className="text-sm text-muted-foreground"
htmlFor="backup-passphrase-words"
>
Words
</label>
<div className="flex flex-1 items-center justify-end gap-3">
<input
className="h-1.5 w-full max-w-30 cursor-pointer appearance-none rounded-full bg-foreground/15 accent-primary"
id="backup-passphrase-words"
data-testid="backup-passphrase-words"
max={MAX_GENERATED_WORDS}
min={MIN_GENERATED_WORDS}
onChange={(event) => setWords(Number(event.target.value))}
type="range"
value={words}
/>
<span className="w-6 text-right text-sm tabular-nums text-foreground">
{words}
</span>
</div>
</div>
<div className="flex items-center justify-between gap-4">
<label
className="text-sm text-muted-foreground"
htmlFor="backup-passphrase-separator"
>
Separator
</label>
<select
className="h-8 rounded-lg border border-border bg-background px-2 text-sm text-foreground outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
id="backup-passphrase-separator"
data-testid="backup-passphrase-separator"
onChange={(event) => setSeparator(event.target.value)}
value={separator}
>
{SEPARATOR_OPTIONS.map((option) => (
<option key={option.label} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
{error ? (
<p
className="flex items-start gap-1.5 text-xs text-destructive"
data-testid="backup-passphrase-generate-error"
>
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
{error}
</p>
) : null}
</PopoverContent>
</Popover>
);
}
/**
* Password-first encrypted key download flow shared by onboarding and
* Settings. The raw private key never enters this component. Rust creates the
* NIP-49 payload locally, then the native save dialog produces the user-owned
* file.
*
* The flow is a single password input; a refresh icon inset in the field
* opens a 1Password-style generator popover (word count + separator).
* Encryption starts eagerly once the password is valid, so Download usually
* opens the save dialog instantly. Background encryption is silent; clicking
* mid-encryption reveals the queued-download ticker until the KDF finishes.
*/
export function EncryptedBackupCreator({
variant = "spotlight",
createButtonPortal,
verifyButtonPortal,
createButtonClassName,
session: sessionProp,
onCreated,
onSaved,
guidedTest = true,
onVerified,
}: EncryptedBackupCreatorProps) {
// Hosts without a longer-lived session get a private one (settings card).
const fallbackSession = useEncryptedBackupSession();
const session = sessionProp ?? fallbackSession;
const { state, dispatch, savedPath, setSavedPath, savedForRef } = session;
const [isRevealed, setIsRevealed] = React.useState(false);
const [saveError, setSaveError] = React.useState<string | null>(null);
const [isSaving, setIsSaving] = React.useState(false);
const [confirmNewPassword, setConfirmNewPassword] = React.useState(false);
const mountedRef = React.useRef(true);
React.useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
// A queued download locks the form — mask the password too so it isn't
// left readable on screen while the user waits for the save dialog.
React.useEffect(() => {
if (state.downloadPending) setIsRevealed(false);
}, [state.downloadPending]);
// Correlate KDF completion by an opaque request id. The password exists only
// in this short-lived effect closure and is cleared from reducer state once
// Rust returns; stale completions cannot commit.
const pendingPassphrase = pendingEncryptPassphrase(state);
const skipDebounce = state.downloadPending;
React.useEffect(() => {
if (!pendingPassphrase) return;
let cancelled = false;
const requestId = state.nextRequestId;
const start = () => {
if (cancelled) return;
dispatch({ type: "encrypt-started", requestId });
void createNcryptsecBackup(pendingPassphrase)
.then((ncryptsec) =>
dispatch({ type: "encrypt-succeeded", requestId, ncryptsec }),
)
.catch((err: unknown) =>
dispatch({
type: "encrypt-failed",
requestId,
message:
err instanceof Error
? err.message
: "Failed to encrypt your key.",
}),
);
};
const timer = window.setTimeout(
start,
skipDebounce ? 0 : ENCRYPT_DEBOUNCE_MS,
);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [dispatch, pendingPassphrase, skipDebounce, state.nextRequestId]);
// Download commit: fires once per committed blob, whether the commit was
// instant (encryption already done) or resolved a queued download. The flow
// only advances to the test view once the file is actually on disk — a
// canceled save dialog or a save failure rolls the commit back to the
// password form so "Download backup" can be clicked again.
React.useEffect(() => {
const ncryptsec = state.ncryptsec;
if (!ncryptsec || savedForRef.current === ncryptsec) return;
savedForRef.current = ncryptsec;
onCreated?.();
setIsSaving(true);
setSaveError(null);
const rollBack = () => {
savedForRef.current = null;
dispatch({ type: "back-to-password" });
};
void saveNcryptsecCopy(ncryptsec)
.then((path) => {
if (path) {
setSavedPath(path);
onSaved?.(path);
} else {
// User canceled the native save dialog — nothing was downloaded.
rollBack();
}
})
.catch((err: unknown) => {
rollBack();
if (mountedRef.current)
setSaveError(
err instanceof Error ? err.message : "Failed to save your key.",
);
})
.finally(() => {
if (mountedRef.current) setIsSaving(false);
});
}, [
dispatch,
onCreated,
onSaved,
savedForRef,
setSavedPath,
state.ncryptsec,
]);
const handleSaveCopy = React.useCallback(async () => {
if (!state.ncryptsec || isSaving) return;
setIsSaving(true);
setSaveError(null);
try {
const path = await saveNcryptsecCopy(state.ncryptsec);
if (mountedRef.current && path) {
setSavedPath(path);
onSaved?.(path);
}
} catch (err) {
if (mountedRef.current)
setSaveError(
err instanceof Error ? err.message : "Failed to save your key.",
);
} finally {
if (mountedRef.current) setIsSaving(false);
}
}, [isSaving, onSaved, setSavedPath, state.ncryptsec]);
const { setVerified, test, setTest } = session;
const handleVerified = React.useCallback(() => {
setVerified(true);
onVerified?.();
}, [onVerified, setVerified]);
const issue = passphraseIssue(state.passphrase);
const showBackupTimeline =
variant === "spotlight" &&
!state.savedPassword &&
!state.createError &&
!saveError;
// The test view requires a successful save, not just a committed blob —
// while the native save dialog is open the password form stays put.
if (state.ncryptsec && savedPath && guidedTest) {
return (
<div data-testid="encrypted-backup-result">
<BackupTestFlow
isSaving={isSaving}
expectedNcryptsec={state.ncryptsec}
onProgressChange={setTest}
onSaveCopy={() => void handleSaveCopy()}
onVerified={handleVerified}
progress={test}
saveError={saveError}
variant={variant}
verifyButtonPortal={verifyButtonPortal}
/>
</div>
);
}
// Without the guided test (settings), a completed save keeps the form
// visible in its saved-password state: masked input, instant re-download,
// and the change-password confirmation guarding any edit.
return (
<div
className={cn("mx-auto w-full max-w-[500px] space-y-3 text-left")}
data-testid="encrypted-backup-creator"
>
<div
className={cn(
"relative",
showBackupTimeline && "pb-32 pt-28 [@media(max-height:40rem)]:py-0",
)}
>
{showBackupTimeline ? <BackupPasswordTimeline /> : null}
<div className="relative z-10">
<Input
aria-label="Encryption password"
autoComplete="new-password"
autoFocus={variant === "spotlight"}
className={cn(
"font-mono",
variant === "spotlight"
? "h-14 rounded-2xl border-black/20 bg-white px-20 text-center text-lg text-black/80 shadow-none placeholder:text-black/55 focus-visible:ring-black/35"
: "h-10 bg-background pr-19",
)}
data-testid="backup-passphrase-input"
disabled={state.downloadPending}
readOnly={state.savedPassword}
aria-describedby={
state.savedPassword
? "backup-saved-password-description"
: undefined
}
onBeforeInput={(event) => {
if (state.savedPassword) {
event.preventDefault();
setConfirmNewPassword(true);
}
}}
onPaste={(event) => {
if (state.savedPassword) {
event.preventDefault();
setConfirmNewPassword(true);
}
}}
onChange={(event) =>
dispatch({ type: "set-passphrase", value: event.target.value })
}
onKeyDown={(event) => {
if (event.key !== "Enter" || event.nativeEvent.isComposing)
return;
event.preventDefault();
if (downloadDisabled(state) || isSaving) return;
if (state.savedPassword && state.ncryptsec) {
void handleSaveCopy();
return;
}
dispatch({ type: "download-clicked" });
}}
placeholder={
state.savedPassword
? ""
: `Password (min ${MIN_PASSPHRASE_LEN} characters)`
}
type={isRevealed ? "text" : "password"}
value={state.passphrase}
/>
{state.savedPassword ? (
<div
aria-hidden
className={cn(
"pointer-events-none absolute inset-y-0 left-3 flex items-center font-mono tracking-widest text-foreground",
variant === "spotlight" && "text-black/80",
)}
data-testid="backup-saved-password-mask"
>
</div>
) : null}
{state.savedPassword ? (
<span className="sr-only" id="backup-saved-password-description">
Backup password saved; hidden for security.
</span>
) : null}
<Button
aria-label={
state.savedPassword
? "Change saved backup password"
: isRevealed
? "Hide password"
: "Reveal password"
}
className={cn(
"absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2 text-muted-foreground hover:text-foreground",
variant === "spotlight" &&
"text-black/55 hover:bg-black/5 hover:text-black/80",
)}
data-testid="backup-passphrase-reveal-toggle"
disabled={state.downloadPending}
onClick={() =>
state.savedPassword
? setConfirmNewPassword(true)
: setIsRevealed((revealed) => !revealed)
}
size="icon"
type="button"
variant="ghost"
>
{isRevealed ? (
<EyeOff className="h-4 w-4" aria-hidden="true" />
) : (
<Eye className="h-4 w-4" aria-hidden="true" />
)}
</Button>
<PassphraseGeneratorPopover
disabled={state.downloadPending}
onRequestGenerate={
state.savedPassword
? () => setConfirmNewPassword(true)
: undefined
}
onGenerated={(value) => {
dispatch({ type: "set-passphrase", value });
// A generated password must be visible so the user can save it.
setIsRevealed(true);
}}
securityTheme={variant === "spotlight"}
/>
{issue ? (
<p
className="absolute left-1 top-full mt-1 animate-in text-xs text-muted-foreground fade-in slide-in-from-top-1 duration-200 motion-reduce:animate-none"
data-testid="backup-passphrase-issue"
>
{issue}
</p>
) : null}
</div>
</div>
{state.savedPassword && state.ncryptsec && savedPath ? (
<div
className="space-y-1 text-center"
data-testid="encrypted-backup-created"
>
<p
className="text-xs text-muted-foreground"
data-testid="encrypted-backup-saved-path"
>
Backup saved to {savedPath}
</p>
<p className="text-xs leading-5 text-muted-foreground">
Your password isn't kept download another copy anytime, or start
over to choose a new password.
</p>
</div>
) : null}
{state.createError ? (
<p
className="text-center text-sm text-destructive"
data-testid="encrypted-backup-create-error"
>
{state.createError}
</p>
) : null}
{saveError ? (
<p
className="text-center text-sm text-destructive"
data-testid="encrypted-backup-save-error"
>
{saveError}
</p>
) : null}
{(() => {
// A queued download gets an explicit progress treatment. Background
// encryption stays silent until the user asks to download.
const createButton = (
<div className="relative">
{state.downloadPending || isSaving ? (
<Spinner
aria-label="Encrypting your key"
className="absolute right-full top-1/2 mr-3 h-4 w-4 -translate-y-1/2 border-2"
data-testid="encrypted-backup-encrypting"
/>
) : null}
<Button
className={cn("h-9 rounded-full px-6", createButtonClassName)}
data-testid="encrypted-backup-create"
disabled={downloadDisabled(state) || isSaving}
onClick={() =>
state.savedPassword && state.ncryptsec
? void handleSaveCopy()
: dispatch({ type: "download-clicked" })
}
type="button"
>
{state.downloadPending ? (
<PendingDownloadTicker />
) : state.savedPassword ? (
"Download backup again"
) : (
"Backup key"
)}
</Button>
</div>
);
// `undefined` = inline (settings); `null` = slot not mounted yet
// (skip a frame rather than flashing the button inline).
if (createButtonPortal === undefined)
return <div className="flex justify-center">{createButton}</div>;
return createButtonPortal
? createPortal(createButton, createButtonPortal)
: null;
})()}
<AlertDialog
open={confirmNewPassword}
onOpenChange={setConfirmNewPassword}
>
<AlertDialogContent
className={cn(
variant === "spotlight" && "buzz-onboarding-security-theme",
)}
data-testid="backup-change-password-dialog"
surface={variant === "spotlight" ? "textured" : "default"}
textureSize="compact"
textureTone={variant === "spotlight" ? "dark" : "light"}
>
<AlertDialogHeader>
<AlertDialogTitle>Create a new backup password?</AlertDialogTitle>
<AlertDialogDescription>
Starting over lets you pick a new password and download a fresh
backup file. Backups you saved earlier will still work just use
the password you created them with.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
className={
variant === "spotlight"
? ONBOARDING_SECONDARY_CTA_CLASS
: undefined
}
>
Keep current backup
</AlertDialogCancel>
<AlertDialogAction
className={
variant === "spotlight"
? ONBOARDING_SECURITY_PRIMARY_CTA_CLASS
: undefined
}
data-testid="backup-start-new-password"
onClick={() => {
dispatch({ type: "start-new-backup" });
setSavedPath(null);
savedForRef.current = null;
setTest(initialBackupTestProgress);
setIsRevealed(false);
}}
>
Start with a new password
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -22,8 +22,8 @@ export function KeyringLockedScreen() {
}, []);
const handleImport = React.useCallback(
async (nsec: string) => {
const identity = await importIdentity(nsec);
async (nsec: string, password?: string) => {
const identity = await importIdentity(nsec, password);
// Update the identity query cache so useIdentityQuery observers see
// locked: false. The bootedLocked latch in hooks.ts will then route
// to RelaunchRequiredScreen via bootedLocked && !identityLocked.
@@ -1,20 +1,33 @@
import * as React from "react";
import type { QueryClient } from "@tanstack/react-query";
import { ArrowUp } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import {
getIdentity,
importIdentity,
persistCurrentIdentity,
} from "@/shared/api/tauriIdentity";
import type { IdentityStorage } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
import { BackupStep } from "./BackupStep";
import { DefaultConfigStep } from "./DefaultConfigStep";
import { DownloadKeyStep } from "./DownloadKeyStep";
import {
backupSessionToPasswordEntry,
resetEncryptedBackupSession,
useEncryptedBackupSession,
} from "./EncryptedBackupCreator";
import { IdentityKeyHelpDialog } from "./IdentityKeyHelpDialog";
import { LandingBees } from "./LandingBees";
import { NostrKeyImportForm } from "./NostrKeyImportForm";
import {
NostrKeyImportForm,
type NostrKeyImportStage,
} from "./NostrKeyImportForm";
import {
ONBOARDING_LANDING_CTA_CLASS,
ONBOARDING_SECONDARY_CTA_CLASS,
OnboardingChrome,
} from "./OnboardingChrome";
import { OnboardingFooterProvider } from "./OnboardingFooter";
@@ -28,6 +41,8 @@ export type MachineOnboardingPage =
| "setup"
| "config";
type BackupSubview = "created" | "options" | "password";
/** A pending navigation the parent should execute after RouterProvider mounts. */
export type PostOnboardingNavigation = {
to: string;
@@ -61,10 +76,27 @@ export function MachineOnboardingFlow({
const [error, setError] = React.useState<string | null>(null);
const [isPending, setIsPending] = React.useState(false);
const [identityWasImported, setIdentityWasImported] = React.useState(false);
const [keyImportStage, setKeyImportStage] =
React.useState<NostrKeyImportStage>("key-entry");
const [selectedPubkey, setSelectedPubkey] = React.useState<string | null>(
null,
);
const [identityStorage, setIdentityStorage] = React.useState<
IdentityStorage | undefined
>();
const [readyRuntimeIds, setReadyRuntimeIds] = React.useState<string[]>([]);
const [backupSubview, setBackupSubview] =
React.useState<BackupSubview>("created");
const [backupDirection, setBackupDirection] = React.useState<
"forward" | "backward"
>("forward");
const [returningFromSecurity, setReturningFromSecurity] =
React.useState(false);
// Owned here so switching between the yellow onboarding view and the dark
// security subview keeps the created backup, password, and test progress.
const backupSession = useEncryptedBackupSession();
const reduceMotion = useReducedMotion() ?? false;
const isSecuritySubview = page === "backup" && backupSubview !== "created";
const handleReadyRuntimeIdsChange = React.useCallback(
(runtimeIds: readonly string[]) => {
setReadyRuntimeIds(Array.from(new Set(runtimeIds)));
@@ -79,6 +111,10 @@ export function MachineOnboardingFlow({
const identity = await getIdentity();
queryClient.setQueryData(["identity"], identity);
setSelectedPubkey(identity.pubkey);
setIdentityStorage(identity.storage);
setBackupDirection("forward");
setReturningFromSecurity(false);
setBackupSubview("created");
setPage("backup");
} catch (cause) {
setError(
@@ -101,6 +137,10 @@ export function MachineOnboardingFlow({
const identity = await persistCurrentIdentity();
queryClient.setQueryData(["identity"], identity);
setSelectedPubkey(identity.pubkey);
setIdentityStorage(identity.storage);
setBackupDirection("forward");
setReturningFromSecurity(false);
setBackupSubview("created");
setPage("backup");
} catch (cause) {
setError(
@@ -112,8 +152,8 @@ export function MachineOnboardingFlow({
}, [queryClient]);
const importExistingIdentity = React.useCallback(
async (nsec: string) => {
const identity = await importIdentity(nsec);
async (nsec: string, password?: string) => {
const identity = await importIdentity(nsec, password);
continueWithIdentity(identity.pubkey);
queryClient.setQueryData(["identity"], identity);
setIdentityWasImported(true);
@@ -126,6 +166,8 @@ export function MachineOnboardingFlow({
return (
<div
className={`buzz-onboarding-neutral-theme buzz-startup-shell flex max-h-dvh items-start justify-center overflow-x-hidden overflow-y-auto px-4 text-foreground ${
isSecuritySubview ? "buzz-onboarding-security-theme" : ""
} ${
page === "identity"
? "buzz-onboarding-welcome py-8"
: "pb-28 pt-[106px]"
@@ -134,7 +176,24 @@ export function MachineOnboardingFlow({
>
<StartupWindowDragRegion />
{page === "identity" ? <LandingBees /> : null}
{page !== "identity" ? (
{isSecuritySubview ? (
<div className="fixed inset-x-0 top-8 z-20 flex justify-center px-6">
<Button
className={`${ONBOARDING_SECONDARY_CTA_CLASS} gap-2 px-5`}
data-testid="backup-return-to-onboarding"
onClick={() => {
setBackupDirection("backward");
setReturningFromSecurity(true);
setBackupSubview("created");
}}
type="button"
variant="ghost"
>
<ArrowUp className="h-4 w-4" aria-hidden="true" />
Return to onboarding
</Button>
</div>
) : page !== "identity" ? (
<OnboardingChrome
current={page === "config" ? 4 : page === "setup" ? 3 : 2}
/>
@@ -178,9 +237,12 @@ export function MachineOnboardingFlow({
: "Create a new identity key"}
</Button>
<Button
className="h-9 rounded-full bg-foreground/10 px-5 hover:bg-foreground/15"
className={`${ONBOARDING_SECONDARY_CTA_CLASS} px-5`}
disabled={isPending}
onClick={() => setPage("key-import")}
onClick={() => {
setKeyImportStage("key-entry");
setPage("key-import");
}}
type="button"
variant="ghost"
>
@@ -198,18 +260,31 @@ export function MachineOnboardingFlow({
effect="fade"
transitionKey="machine-key-import"
>
<div className="shrink-0">
<motion.div
animate={{ opacity: 1, y: 0 }}
className="shrink-0"
initial={reduceMotion ? false : { opacity: 0, y: 10 }}
key={keyImportStage}
transition={{
duration: reduceMotion ? 0 : 0.3,
ease: "easeOut",
}}
>
<h1 className="text-title font-normal text-foreground">
{identityLost
? "Re-import your key"
: "Enter your private key"}
{keyImportStage === "backup-password"
? "Unlock your account"
: identityLost
? "Re-import your key"
: "Enter your private key"}
</h1>
<p className="mt-5 max-w-[440px] text-sm leading-6 text-foreground/80">
{identityLost
? "Your identity is no longer in the system keyring. Re-import your nsec to restore it."
: "If you already have a Buzz account, enter your private key below to get started."}
{keyImportStage === "backup-password"
? "Enter your backup password to unlock your key and restore your identity."
: identityLost
? "Your identity is no longer in the system keyring. Re-import your nsec to restore it."
: "If you already have a Buzz account, enter your private key below to get started."}
</p>
</div>
</motion.div>
<div className="buzz-onboarding-key-import-position w-full">
<NostrKeyImportForm
backLabel={identityLost ? "Start new identity" : "Back"}
@@ -219,21 +294,62 @@ export function MachineOnboardingFlow({
: () => setPage("identity")
}
onImport={importExistingIdentity}
onStageChange={setKeyImportStage}
variant="spotlight"
/>
</div>
</OnboardingSlideTransition>
) : page === "backup" ? (
<BackupStep
direction="forward"
onBack={() => setPage("identity")}
onNext={() => setPage("setup")}
/>
backupSubview === "password" ? (
<DownloadKeyStep
direction={backupDirection}
onBack={() => {
resetEncryptedBackupSession(backupSession);
setBackupDirection("backward");
setReturningFromSecurity(false);
setBackupSubview("options");
}}
session={backupSession}
/>
) : (
<BackupStep
direction={backupDirection}
identityStorage={identityStorage}
onBack={() => setPage("identity")}
onNext={() => setPage("setup")}
onOpenPasswordBackup={() => {
resetEncryptedBackupSession(backupSession);
setBackupDirection("forward");
setReturningFromSecurity(false);
setBackupSubview("password");
}}
onShowOptions={() => {
setBackupDirection("forward");
setReturningFromSecurity(false);
setBackupSubview("options");
}}
optionsExpanded={backupSubview === "options"}
returningFromSecurity={returningFromSecurity}
/>
)
) : page === "setup" ? (
<SetupStep
actions={{
back: () =>
setPage(identityWasImported ? "key-import" : "backup"),
// Fresh-key users return to whichever identity backup subview
// they used to reach setup; imported keys skip backup entirely.
back: () => {
if (identityWasImported) {
setKeyImportStage("key-entry");
setPage("key-import");
return;
}
if (backupSubview === "password") {
backupSessionToPasswordEntry(backupSession);
}
setBackupDirection("backward");
setReturningFromSecurity(false);
setPage("backup");
},
next: (runtimeIds) => {
const ids = Array.from(runtimeIds);
setReadyRuntimeIds(ids);
@@ -3,21 +3,33 @@ import { Check, Eye, EyeOff, KeyRound } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { nsecToNpub } from "@/shared/lib/nostrUtils";
import {
classifyKeyImportInput,
isPlausibleNcryptsec,
keyImportSubmitEnabled,
} from "../lib/keyImportInput";
import { Button } from "@/shared/ui/button";
import { Card } from "@/shared/ui/card";
import { Input } from "@/shared/ui/input";
import { Spinner } from "@/shared/ui/spinner";
import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
import {
ONBOARDING_PRIMARY_CTA_CLASS,
ONBOARDING_SECONDARY_CTA_CLASS,
} from "./OnboardingChrome";
import { BackupPasswordTimeline } from "./BackupPasswordTimeline";
import { OnboardingFooter } from "./OnboardingFooter";
const NOSTR_KEY_FILE_MAX_BYTES = 1024;
export type NostrKeyImportStage = "key-entry" | "backup-password";
type NostrKeyImportFormProps = {
backLabel?: string;
disabled?: boolean;
errorMessage?: string | null;
onBack: () => void;
onImport: (nsec: string) => Promise<void>;
onImport: (nsec: string, password?: string) => Promise<void>;
onStageChange?: (stage: NostrKeyImportStage) => void;
/** "spotlight" is the first-launch treatment: glowy centered input, no drop zone, pill buttons. */
variant?: "default" | "spotlight";
};
@@ -35,18 +47,24 @@ export function NostrKeyImportForm({
errorMessage: externalErrorMessage = null,
onBack,
onImport,
onStageChange,
variant = "default",
}: NostrKeyImportFormProps) {
const [nsecInput, setNsecInput] = React.useState("");
const [passphrase, setPassphrase] = React.useState("");
const [isImporting, setIsImporting] = React.useState(false);
const [importError, setImportError] = React.useState<string | null>(null);
const [isDragging, setIsDragging] = React.useState(false);
const [isRevealed, setIsRevealed] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement | null>(null);
const passphraseInputRef = React.useRef<HTMLInputElement | null>(null);
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
const previewNpub = React.useMemo(() => nsecToNpub(nsecInput), [nsecInput]);
const trimmedInput = nsecInput.trim();
const hasInput = trimmedInput.length > 0;
const inputKind = classifyKeyImportInput(nsecInput);
const isEncryptedInput = inputKind === "ncryptsec";
const isPasswordStage = isPlausibleNcryptsec(nsecInput);
// Masked-by-default must re-assert whenever the field empties: a sticky
// reveal from a previous key must never apply to newly pasted content the
@@ -56,14 +74,33 @@ export function NostrKeyImportForm({
setIsRevealed(false);
}
}, [hasInput]);
const isValid = previewNpub !== null;
// A stale passphrase must never ride along when the input stops being an
// encrypted backup (cleared field, or replaced with a raw nsec).
React.useEffect(() => {
if (!isPasswordStage) {
setPassphrase("");
}
}, [isPasswordStage]);
const isValid = keyImportSubmitEnabled(nsecInput, passphrase);
const isInteractionDisabled = disabled || isImporting;
const showInvalidHint = hasInput && !isValid && trimmedInput.length >= 5;
const showInvalidHint =
hasInput &&
!isPasswordStage &&
previewNpub === null &&
trimmedInput.length >= 5;
const errorMessage = importError ?? externalErrorMessage;
React.useLayoutEffect(() => {
inputRef.current?.focus();
}, []);
if (isPasswordStage) {
passphraseInputRef.current?.focus();
} else {
inputRef.current?.focus();
}
}, [isPasswordStage]);
React.useEffect(() => {
onStageChange?.(isPasswordStage ? "backup-password" : "key-entry");
}, [isPasswordStage, onStageChange]);
const openFilePicker = React.useCallback(() => {
if (isInteractionDisabled) {
@@ -81,7 +118,7 @@ export function NostrKeyImportForm({
if (file.size > NOSTR_KEY_FILE_MAX_BYTES) {
setImportError(
"That file is too large to be a key. Drop a .key file or paste your nsec.",
"That file is too large to be a key backup or private key. Choose another file.",
);
return;
}
@@ -108,9 +145,13 @@ export function NostrKeyImportForm({
return;
}
if (!previewNpub) {
if (!isValid) {
setImportError(
"That doesn't look like a valid nsec. Paste an nsec1 key.",
isPasswordStage
? "Enter the password for this key backup."
: isEncryptedInput
? "That doesn't look like a complete ncryptsec backup."
: "That doesn't look like a valid nsec. Paste an nsec1 key.",
);
return;
}
@@ -119,7 +160,7 @@ export function NostrKeyImportForm({
setImportError(null);
try {
await onImport(trimmedInput);
await onImport(trimmedInput, isPasswordStage ? passphrase : undefined);
} catch (error) {
setImportError(
error instanceof Error ? error.message : "Couldn't import this key.",
@@ -127,7 +168,28 @@ export function NostrKeyImportForm({
} finally {
setIsImporting(false);
}
}, [isInteractionDisabled, onImport, previewNpub, trimmedInput]);
}, [
isEncryptedInput,
isInteractionDisabled,
isPasswordStage,
isValid,
onImport,
passphrase,
trimmedInput,
]);
const handleBack = React.useCallback(() => {
if (!isPasswordStage) {
onBack();
return;
}
setNsecInput("");
setPassphrase("");
setImportError(null);
setIsRevealed(false);
onStageChange?.("key-entry");
}, [isPasswordStage, onBack, onStageChange]);
return (
<form
@@ -137,221 +199,296 @@ export function NostrKeyImportForm({
void handleSubmit();
}}
>
<div className="space-y-1.5 text-left">
<label
className={cn(
"text-sm font-medium text-foreground",
variant === "spotlight" && "sr-only",
)}
htmlFor="nostr-private-key"
>
Private key
</label>
{variant === "spotlight" ? (
<Card
className="w-full px-8 py-12"
data-testid="nostr-import-card"
variant="textured"
>
<div className="relative w-full">
<Input
autoComplete="off"
autoCorrect="off"
// Symmetric px reserves the absolutely positioned toggle's
// footprint on BOTH sides, so the centered key text never
// runs under the eye control and stays optically centered.
className="h-[3.6875rem] rounded-none border-0 bg-transparent px-10 text-center font-mono !text-4xl text-[color:var(--buzz-onboarding-backup-ink)] shadow-none placeholder:text-foreground/30 focus-visible:ring-0"
data-testid="nostr-import-nsec-input"
id="nostr-private-key"
onChange={(event) => {
setNsecInput(event.target.value);
setImportError(null);
}}
placeholder="Enter your key here"
ref={inputRef}
spellCheck={false}
type={isRevealed ? "text" : "password"}
value={nsecInput}
/>
{/* Absolutely positioned so appearing/disappearing never resizes
the input or shifts its centered text; fades with hasInput. */}
<Button
aria-hidden={!hasInput}
aria-label={
isRevealed ? "Hide private key" : "Reveal private key"
}
className={cn(
"absolute right-8 top-1/2 h-10 w-10 -translate-y-1/2 text-muted-foreground transition-opacity duration-300 hover:bg-foreground/10 hover:text-foreground motion-reduce:transition-none",
hasInput ? "opacity-100" : "pointer-events-none opacity-0",
)}
data-testid="nostr-import-reveal-toggle"
onClick={() => setIsRevealed((current) => !current)}
size="icon"
tabIndex={hasInput ? 0 : -1}
type="button"
variant="ghost"
>
{isRevealed ? (
<EyeOff aria-hidden="true" className="h-6 w-6" />
) : (
<Eye aria-hidden="true" className="h-6 w-6" />
)}
</Button>
</div>
</Card>
) : (
<Input
autoComplete="off"
autoCorrect="off"
className="h-10 bg-background"
data-testid="nostr-import-nsec-input"
id="nostr-private-key"
onChange={(event) => {
setNsecInput(event.target.value);
setImportError(null);
}}
placeholder="nsec1..."
ref={inputRef}
spellCheck={false}
type="password"
value={nsecInput}
/>
)}
</div>
{variant === "spotlight" ? null : (
<>
<input
accept=".key,text/plain"
className="sr-only"
disabled={isInteractionDisabled}
onChange={(event) => {
void handleFiles(event.currentTarget.files);
event.currentTarget.value = "";
}}
ref={fileInputRef}
tabIndex={-1}
type="file"
/>
<button
{!isPasswordStage ? (
<div className="space-y-1.5 text-left">
<label
className={cn(
"relative flex h-[120px] flex-col items-center justify-center gap-3 overflow-hidden rounded-xl border border-transparent bg-muted text-foreground transition-[background-color,border-color,box-shadow,color] duration-[250ms] ease-out hover:bg-muted/80 disabled:opacity-60",
isDragging &&
"border-primary bg-primary/10 text-primary ring-1 ring-primary/35 hover:bg-primary/10",
"text-sm font-medium text-foreground",
variant === "spotlight" && "sr-only",
)}
data-dragging={isDragging ? "true" : undefined}
data-testid="nostr-import-drop"
htmlFor="nostr-private-key"
>
Private key
</label>
{variant === "spotlight" ? (
<Card
className="w-full px-8 py-12"
data-testid="nostr-import-card"
variant="textured"
>
<div className="relative w-full">
<Input
autoComplete="off"
autoCorrect="off"
// Symmetric px reserves the absolutely positioned toggle's
// footprint on BOTH sides, so the centered key text never
// runs under the eye control and stays optically centered.
className="h-[3.6875rem] rounded-none border-0 bg-transparent px-10 text-center font-mono !text-4xl text-[color:var(--buzz-onboarding-backup-ink)] shadow-none placeholder:text-foreground/30 focus-visible:ring-0"
data-testid="nostr-import-nsec-input"
id="nostr-private-key"
onChange={(event) => {
setNsecInput(event.target.value);
setImportError(null);
}}
placeholder="Enter your key here"
ref={inputRef}
spellCheck={false}
type={isRevealed ? "text" : "password"}
value={nsecInput}
/>
{/* Absolutely positioned so appearing/disappearing never resizes
the input or shifts its centered text; fades with hasInput. */}
<Button
aria-hidden={!hasInput}
aria-label={
isRevealed ? "Hide private key" : "Reveal private key"
}
className={cn(
"absolute right-8 top-1/2 h-10 w-10 -translate-y-1/2 text-muted-foreground transition-opacity duration-300 hover:bg-foreground/10 hover:text-foreground motion-reduce:transition-none",
hasInput ? "opacity-100" : "pointer-events-none opacity-0",
)}
data-testid="nostr-import-reveal-toggle"
onClick={() => setIsRevealed((current) => !current)}
size="icon"
tabIndex={hasInput ? 0 : -1}
type="button"
variant="ghost"
>
{isRevealed ? (
<EyeOff aria-hidden="true" className="h-6 w-6" />
) : (
<Eye aria-hidden="true" className="h-6 w-6" />
)}
</Button>
</div>
</Card>
) : (
<Input
autoComplete="off"
autoCorrect="off"
className="h-10 bg-background"
data-testid="nostr-import-nsec-input"
id="nostr-private-key"
onChange={(event) => {
setNsecInput(event.target.value);
setImportError(null);
}}
placeholder="nsec1..."
ref={inputRef}
spellCheck={false}
type="password"
value={nsecInput}
/>
)}
</div>
) : null}
{/* Hidden file input shared by both variants: the default drop zone and
the spotlight "Choose a backup file" button both open it. Accepts the
.ncryptsec backups our own save flow emits alongside raw .key files. */}
<input
accept=".key,.ncryptsec,text/plain"
className="sr-only"
data-testid="nostr-import-file-input"
disabled={isInteractionDisabled}
onChange={(event) => {
void handleFiles(event.currentTarget.files);
event.currentTarget.value = "";
}}
ref={fileInputRef}
tabIndex={-1}
type="file"
/>
{!isPasswordStage && variant === "spotlight" ? (
// First-launch/wiped-identity treatment: no drop zone, but the file
// path must still exist — a backup saved through the OS dialog is
// exactly what a wiped user returns with.
<div className="mt-2 text-center">
<Button
className={ONBOARDING_SECONDARY_CTA_CLASS}
data-testid="nostr-import-file-button"
disabled={isInteractionDisabled}
onClick={openFilePicker}
onDragEnter={(event) => {
event.preventDefault();
event.stopPropagation();
if (!isInteractionDisabled) {
setIsDragging(true);
}
}}
onDragLeave={(event) => {
event.preventDefault();
event.stopPropagation();
if (
event.currentTarget.contains(event.relatedTarget as Node | null)
) {
return;
}
setIsDragging(false);
}}
onDragOver={(event) => {
event.preventDefault();
event.stopPropagation();
if (!isInteractionDisabled) {
event.dataTransfer.dropEffect = "copy";
}
}}
onDrop={(event) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
if (isInteractionDisabled) {
return;
}
void handleFiles(event.dataTransfer.files);
}}
type="button"
variant="ghost"
>
<span
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-0 rounded-[inherit] bg-primary/10 opacity-0 transition-opacity duration-[250ms] ease-out",
isDragging && "opacity-100",
)}
/>
<KeyRound
className={cn(
"relative h-8 w-8 text-muted-foreground transition-colors duration-[250ms] ease-out",
isDragging && "text-primary",
)}
/>
<span
className={cn(
"relative text-sm font-medium text-muted-foreground transition-colors duration-[250ms] ease-out",
isDragging && "text-primary",
)}
>
Drop a key here
</span>
</button>
</>
)}
Choose a backup file
</Button>
</div>
) : !isPasswordStage ? (
<button
className={cn(
"relative flex h-[120px] flex-col items-center justify-center gap-3 overflow-hidden rounded-xl border border-transparent bg-muted text-foreground transition-[background-color,border-color,box-shadow,color] duration-[250ms] ease-out hover:bg-muted/80 disabled:opacity-60",
isDragging &&
"border-primary bg-primary/10 text-primary ring-1 ring-primary/35 hover:bg-primary/10",
)}
data-dragging={isDragging ? "true" : undefined}
data-testid="nostr-import-drop"
disabled={isInteractionDisabled}
onClick={openFilePicker}
onDragEnter={(event) => {
event.preventDefault();
event.stopPropagation();
if (!isInteractionDisabled) {
setIsDragging(true);
}
}}
onDragLeave={(event) => {
event.preventDefault();
event.stopPropagation();
if (
event.currentTarget.contains(event.relatedTarget as Node | null)
) {
return;
}
setIsDragging(false);
}}
onDragOver={(event) => {
event.preventDefault();
event.stopPropagation();
if (!isInteractionDisabled) {
event.dataTransfer.dropEffect = "copy";
}
}}
onDrop={(event) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
if (isInteractionDisabled) {
return;
}
void handleFiles(event.dataTransfer.files);
}}
type="button"
>
<span
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-0 rounded-[inherit] bg-primary/10 opacity-0 transition-opacity duration-[250ms] ease-out",
isDragging && "opacity-100",
)}
/>
<KeyRound
className={cn(
"relative h-8 w-8 text-muted-foreground transition-colors duration-[250ms] ease-out",
isDragging && "text-primary",
)}
/>
<span
className={cn(
"relative text-sm font-medium text-muted-foreground transition-colors duration-[250ms] ease-out",
isDragging && "text-primary",
)}
>
Drop a key here
</span>
</button>
) : null}
<div
className={cn("min-h-8", variant === "spotlight" && "mt-6 text-center")}
data-testid="nostr-import-feedback"
>
{previewNpub ? (
variant === "spotlight" ? (
// Spotlight uses the backup step's quiet caption language:
// centered, unboxed, with the npub in the shared olive key ink.
<div
className="space-y-1 text-sm"
data-testid="nostr-import-npub-preview"
{isPasswordStage ? (
<div
className="relative mx-auto w-full max-w-[500px] pb-32 pt-32 [@media(max-height:40rem)]:py-0"
data-testid="nostr-import-passphrase-section"
>
<BackupPasswordTimeline mode="restore" />
<label className="sr-only" htmlFor="nostr-import-passphrase">
Backup password
</label>
<div className="relative z-10">
<Input
autoComplete="current-password"
autoCorrect="off"
className="h-14 rounded-2xl border-black/20 bg-white px-12 text-center font-mono text-lg text-black/80 shadow-none placeholder:text-black/55 focus-visible:ring-black/35"
data-testid="nostr-import-passphrase"
id="nostr-import-passphrase"
onChange={(event) => {
setPassphrase(event.target.value);
setImportError(null);
}}
placeholder="Backup password"
ref={passphraseInputRef}
spellCheck={false}
type={isRevealed ? "text" : "password"}
value={passphrase}
/>
<Button
aria-label={isRevealed ? "Hide password" : "Reveal password"}
className="absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2 text-black/55 hover:bg-black/5 hover:text-black/80"
data-testid="nostr-import-passphrase-reveal-toggle"
disabled={isInteractionDisabled}
onClick={() => setIsRevealed((current) => !current)}
size="icon"
type="button"
variant="ghost"
>
<p className="flex items-center justify-center gap-1.5 text-foreground">
<Check aria-hidden="true" className="h-4 w-4 shrink-0" />
Nostr identity found
</p>
<p className="break-all font-mono text-[color:var(--buzz-onboarding-backup-ink)]">
{previewNpub}
</p>
</div>
) : (
<div
className="flex items-start gap-2 rounded-md border border-primary/30 bg-primary/5 px-3 py-2 text-xs"
data-testid="nostr-import-npub-preview"
>
<Check className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
<div className="min-w-0 space-y-0.5">
<p className="font-medium text-foreground">
This will use this Nostr identity:
{isRevealed ? (
<EyeOff aria-hidden="true" className="h-4 w-4" />
) : (
<Eye aria-hidden="true" className="h-4 w-4" />
)}
</Button>
</div>
</div>
) : null}
{!isPasswordStage || errorMessage ? (
<div
className={cn(
"min-h-8",
variant === "spotlight" && "mt-6 text-center",
)}
data-testid="nostr-import-feedback"
>
{!isPasswordStage && previewNpub ? (
variant === "spotlight" ? (
// Spotlight uses the backup step's quiet caption language:
// centered, unboxed, with the npub in the shared olive key ink.
<div
className="space-y-1 text-sm"
data-testid="nostr-import-npub-preview"
>
<p className="flex items-center justify-center gap-1.5 text-foreground">
<Check aria-hidden="true" className="h-4 w-4 shrink-0" />
Nostr identity found
</p>
<p className="break-all font-mono text-2xs text-muted-foreground">
<p className="break-all font-mono text-[color:var(--buzz-onboarding-backup-ink)]">
{previewNpub}
</p>
</div>
</div>
)
) : null}
) : (
<div
className="flex items-start gap-2 rounded-md border border-primary/30 bg-primary/5 px-3 py-2 text-xs"
data-testid="nostr-import-npub-preview"
>
<Check className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
<div className="min-w-0 space-y-0.5">
<p className="font-medium text-foreground">
This will use this Nostr identity:
</p>
<p className="break-all font-mono text-2xs text-muted-foreground">
{previewNpub}
</p>
</div>
</div>
)
) : null}
{showInvalidHint && !errorMessage ? (
<p className="text-sm text-muted-foreground">
Waiting for a valid nsec1 key
</p>
) : null}
{showInvalidHint && !errorMessage ? (
<p className="text-sm text-muted-foreground">
{isEncryptedInput
? "Waiting for a complete ncryptsec backup"
: "Waiting for a valid nsec1 key"}
</p>
) : null}
{errorMessage ? (
<p className="text-center text-sm text-destructive">{errorMessage}</p>
) : null}
</div>
{errorMessage ? (
<p className="text-center text-sm text-destructive">
{errorMessage}
</p>
) : null}
</div>
) : null}
<OnboardingFooter>
<Button
@@ -381,15 +518,15 @@ export function NostrKeyImportForm({
<Button
className={
variant === "spotlight"
? "h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
? ONBOARDING_SECONDARY_CTA_CLASS
: "h-10 w-full text-muted-foreground hover:text-accent-foreground"
}
disabled={isImporting}
onClick={onBack}
onClick={handleBack}
type="button"
variant="ghost"
>
{backLabel}
{isPasswordStage ? "Back" : backLabel}
</Button>
</OnboardingFooter>
</form>
@@ -2,8 +2,8 @@ import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark";
/**
* Positions in the first-launch flow: landing, identity/key, harness setup,
* default config, community choice, community profile, meet the team. Used as
* the default pagination length when a flow doesn't pass an explicit total.
* default config, community choice, community profile, meet the team. Password
* backup is an optional subview of identity/key, not another position.
*/
export const TOTAL_ONBOARDING_PAGES = 7;
@@ -17,6 +17,9 @@ const ONBOARDING_CTA_SHAPE = "h-[2.375rem] rounded-full px-6";
*/
export const ONBOARDING_PRIMARY_CTA_CLASS = `${ONBOARDING_CTA_SHAPE} text-[var(--buzz-onboarding-cta-label)]`;
/** Inverted primary action used only on dark backup-security surfaces. */
export const ONBOARDING_SECURITY_PRIMARY_CTA_CLASS = `${ONBOARDING_CTA_SHAPE} bg-white text-black/80 hover:bg-white/90 hover:text-black`;
/**
* Primary-CTA styling for the landing screen only: the shared pill with the
* chartreuse label (`--buzz-welcome-chartreuse`). The blue label is reserved
@@ -24,6 +27,10 @@ export const ONBOARDING_PRIMARY_CTA_CLASS = `${ONBOARDING_CTA_SHAPE} text-[var(-
*/
export const ONBOARDING_LANDING_CTA_CLASS = `${ONBOARDING_CTA_SHAPE} text-[var(--buzz-welcome-chartreuse)]`;
/** Shared quiet pill for secondary actions throughout onboarding. */
export const ONBOARDING_SECONDARY_CTA_CLASS =
"h-9 rounded-full bg-foreground/10 px-6 text-foreground hover:bg-foreground/15 hover:text-foreground";
/**
* Icon-control styling for onboarding surfaces that sit on the textured card:
* olive backup ink (`--buzz-onboarding-backup-ink`) with a plain
@@ -34,6 +41,10 @@ export const ONBOARDING_LANDING_CTA_CLASS = `${ONBOARDING_CTA_SHAPE} text-[var(-
export const ONBOARDING_INK_ICON_CLASS =
"text-[color:var(--buzz-onboarding-backup-ink)] hover:bg-transparent hover:text-foreground";
/** Icon controls on the dark noisy backup surfaces stay visually unboxed. */
export const ONBOARDING_SECURITY_ICON_CLASS =
"text-muted-foreground hover:bg-transparent hover:text-foreground";
/**
* Shared onboarding chrome shown on every page after the landing screen: a
* static Buzz mark pinned to the top-left, and a centered pagination track that
@@ -388,8 +388,8 @@ export function OnboardingFlow({
// key's relay profile reseeds the steps, and a key that already finished
// onboarding on this machine skips straight into the app.
const importExistingKey = React.useCallback(
async (nsec: string) => {
const identity = await importIdentity(nsec);
async (nsec: string, password?: string) => {
const identity = await importIdentity(nsec, password);
relayClient.disconnect();
queryClient.setQueryData(["identity"], identity);
queryClient.removeQueries({ queryKey: profileQueryKey });
@@ -14,6 +14,7 @@ export type OnboardingTransitionDirection = "forward" | "backward";
export type OnboardingTransitionEffect =
| "fade"
| "line-slide"
| "mask-reveal-down"
| "mask-reveal-up"
| "none";
@@ -698,25 +698,28 @@ function SetupStepContent({
/>
<OnboardingFooter>
<Button
className={`${ONBOARDING_PRIMARY_CTA_CLASS} text-sm`}
data-testid="onboarding-setup-next"
disabled={readyRuntimeIds.length === 0}
onClick={() => actions.next(readyRuntimeIds)}
type="button"
>
Next
</Button>
<Button
className="h-9 rounded-full bg-foreground/10 px-6 text-sm hover:bg-foreground/15"
data-testid="onboarding-setup-skip"
onClick={() => actions.next([])}
type="button"
variant="ghost"
>
Skip for now
</Button>
{/* Relative row keeps the primary CTA truly centered while Skip
hangs off its right edge without shifting the center. */}
<div className="relative flex items-center justify-center">
<Button
className={`${ONBOARDING_PRIMARY_CTA_CLASS} text-sm`}
data-testid="onboarding-setup-next"
disabled={readyRuntimeIds.length === 0}
onClick={() => actions.next(readyRuntimeIds)}
type="button"
>
Next
</Button>
<Button
className="absolute left-full ml-3 h-9 animate-in whitespace-nowrap rounded-full px-6 text-sm fade-in fill-mode-backwards [animation-delay:1000ms] animation-duration-[500ms] hover:bg-foreground/10 motion-reduce:animate-none"
data-testid="onboarding-setup-skip"
onClick={() => actions.next([])}
type="button"
variant="ghost"
>
Skip for now
</Button>
</div>
<Button
className="h-9 rounded-full bg-foreground/10 px-6 text-sm hover:bg-foreground/15"
@@ -53,29 +53,11 @@ test("currentStep_falls_back_to_1_for_pages_outside_the_step_list", () => {
});
// ---------------------------------------------------------------------------
// BackupStep gating: backupNextDisabled() pure helper
// BackupStep gating: saving a password-protected backup is recommended, not required
// ---------------------------------------------------------------------------
test("backup_next_disabled_while_loading", () => {
// During a slow keychain read, Next must be blocked — user cannot race past
// the key display before it is shown.
assert.equal(backupNextDisabled({ isLoading: true, loadError: null }), true);
});
test("backup_next_disabled_on_load_error", () => {
// Error state: only the explicit "Skip for now" ghost advances; Next blocked.
assert.equal(
backupNextDisabled({ isLoading: false, loadError: "IPC error" }),
true,
);
});
test("backup_next_enabled_after_clean_load", () => {
// Key shown (or backend cleanly returned none) — user may proceed.
assert.equal(
backupNextDisabled({ isLoading: false, loadError: null }),
false,
);
test("backup_next_is_always_enabled", () => {
assert.equal(backupNextDisabled(), false);
});
// ---------------------------------------------------------------------------
@@ -31,13 +31,13 @@ export const SIGNOUT_CONFIRM_PHRASE = "wipe all my data";
* Signing out wipes the identity key and all local data, so the confirm
* dialog gates the delete button behind two explicit steps:
*
* 1. Back up the key the nsec is shown inline (masked, with reveal/copy);
* the "I have saved my private key" checkbox unlocks only after the user
* actually reveals or copies the key.
* 1. Confirm recovery Settings offers a tested password-protected backup;
* the dialog also shows the raw nsec as a last-chance fallback, and the
* user checks a box confirming they can restore their identity.
* 2. Typed confirmation the user must type the exact phrase
* "wipe all my data".
*
* Only when both gates pass does "Delete My Data" become clickable.
* Only when both gates pass does "Delete my data" become clickable.
*/
export function SignOutSection() {
const [isOpen, setIsOpen] = React.useState(false);
@@ -47,7 +47,6 @@ export function SignOutSection() {
const [nsec, setNsec] = React.useState<string | null>(null);
const [nsecError, setNsecError] = React.useState<string | null>(null);
const [isNsecLoading, setIsNsecLoading] = React.useState(false);
const [hasInteractedWithKey, setHasInteractedWithKey] = React.useState(false);
const [hasConfirmedBackup, setHasConfirmedBackup] = React.useState(false);
// Guards against a late-resolving getNsec() repopulating state after the
// dialog closes.
@@ -58,20 +57,13 @@ export function SignOutSection() {
const isPhraseConfirmed =
confirmText.trim().toLowerCase() === SIGNOUT_CONFIRM_PHRASE;
// The backup checkbox unlocks after real interaction with the key
// (reveal or copy). If the key cannot be loaded at all there is nothing to
// interact with — let the user proceed past the backup step rather than
// locking them out of sign-out entirely.
const isBackupGateSatisfied = hasConfirmedBackup;
const canConfirmBackup = hasInteractedWithKey || nsecError !== null;
const canDelete = isBackupGateSatisfied && isPhraseConfirmed && !isPending;
const canDelete = hasConfirmedBackup && isPhraseConfirmed && !isPending;
function resetDialogState() {
fetchCancelledRef.current = true;
setNsec(null);
setNsecError(null);
setIsNsecLoading(false);
setHasInteractedWithKey(false);
setHasConfirmedBackup(false);
setConfirmText("");
}
@@ -137,7 +129,8 @@ export function SignOutSection() {
<h2 className="text-lg font-semibold tracking-tight">Sign out</h2>
<p className="text-sm text-muted-foreground">
Removes your identity key and all local app data from this device.
Back up your private key (nsec) first this cannot be undone.
Before signing out, create and test a password-protected key backup
above this cannot be undone.
</p>
</div>
<Button
@@ -175,7 +168,7 @@ export function SignOutSection() {
<div className="space-y-3">
<p className="text-sm font-medium">
1. Back up your private key (nsec)
1. Confirm you can restore your identity
</p>
{isNsecLoading ? (
<p className="text-sm text-muted-foreground">Loading</p>
@@ -187,10 +180,7 @@ export function SignOutSection() {
{nsecError}
</p>
) : nsec ? (
<NsecMaskedDisplay
nsec={nsec}
onKeyInteraction={() => setHasInteractedWithKey(true)}
/>
<NsecMaskedDisplay nsec={nsec} />
) : null}
<label
className="flex cursor-pointer items-start gap-2.5 text-sm has-[button:disabled]:cursor-not-allowed has-[button:disabled]:opacity-60"
@@ -201,19 +191,15 @@ export function SignOutSection() {
checked={hasConfirmedBackup}
className="mt-0.5"
data-testid="signout-backup-confirm"
disabled={!canConfirmBackup || isPending}
disabled={isPending}
id="signout-backup-confirm"
onCheckedChange={(checked) =>
setHasConfirmedBackup(checked === true)
}
/>
<span>
I have saved my private key somewhere safe.
{!canConfirmBackup ? (
<span className="block text-xs text-muted-foreground">
Reveal or copy the key above first.
</span>
) : null}
I have tested a key backup or saved this private key somewhere
safe.
</span>
</label>
</div>
+28
View File
@@ -0,0 +1,28 @@
export type IdentityStorage =
| "system-keyring"
| "local-file"
| "environment"
| "ephemeral";
export type Identity = {
pubkey: string;
displayName: string;
/** Durable location of the active identity key. Older/mock bridges may omit
* this until they adopt identity storage reporting. */
storage?: IdentityStorage;
/** True when the app booted in "identity lost" recovery mode the OS
* keyring was empty despite a prior successful migration. The frontend
* should route to nsec re-import instead of normal onboarding.
* Mutually exclusive with `locked`. */
lost?: boolean;
/** True when the app booted with an ephemeral key because the OS keyring
* holding the real identity is UNREACHABLE (e.g. GNOME Keyring / KWallet
* locked). The real key still exists; no in-app recovery is possible
* the user must unlock the keyring externally and relaunch.
* Mutually exclusive with `lost`. */
locked?: boolean;
/** True when the boot-time Phase 2 reset attempted a wipe but verification
* failed. Identity resolution was skipped; the sentinel is preserved so
* the next relaunch retries the wipe automatically. */
resetFailed?: boolean;
};
+8 -3
View File
@@ -1,9 +1,10 @@
import { invokeTauri } from "@/shared/api/tauri";
import type { Identity } from "@/shared/api/types";
import type { Identity, IdentityStorage } from "@/shared/api/types";
type RawIdentity = {
pubkey: string;
display_name: string;
storage?: IdentityStorage;
lost?: boolean;
locked?: boolean;
reset_failed?: boolean;
@@ -13,6 +14,7 @@ function fromRawIdentity(raw: RawIdentity): Identity {
return {
pubkey: raw.pubkey,
displayName: raw.display_name,
storage: raw.storage,
lost: raw.lost === true,
locked: raw.locked === true,
resetFailed: raw.reset_failed === true,
@@ -27,9 +29,12 @@ export async function getNsec(): Promise<string> {
return invokeTauri<string>("get_nsec");
}
export async function importIdentity(nsec: string): Promise<Identity> {
export async function importIdentity(
nsec: string,
password?: string,
): Promise<Identity> {
return fromRawIdentity(
await invokeTauri<RawIdentity>("import_identity", { nsec }),
await invokeTauri<RawIdentity>("import_identity", { nsec, password }),
);
}
+1 -19
View File
@@ -103,25 +103,7 @@ export type AddChannelMembersResult = {
}>;
};
export type Identity = {
pubkey: string;
displayName: string;
/** True when the app booted in "identity lost" recovery mode the OS
* keyring was empty despite a prior successful migration. The frontend
* should route to nsec re-import instead of normal onboarding.
* Mutually exclusive with `locked`. */
lost?: boolean;
/** True when the app booted with an ephemeral key because the OS keyring
* holding the real identity is UNREACHABLE (e.g. GNOME Keyring / KWallet
* locked). The real key still exists; no in-app recovery is possible
* the user must unlock the keyring externally and relaunch.
* Mutually exclusive with `lost`. */
locked?: boolean;
/** True when the boot-time Phase 2 reset attempted a wipe but verification
* failed. Identity resolution was skipped; the sentinel is preserved so
* the next relaunch retries the wipe automatically. */
resetFailed?: boolean;
};
export type { Identity, IdentityStorage } from "./identityTypes";
export type Profile = {
pubkey: string;
@@ -0,0 +1,74 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
// Structural tripwire (plan D4, defense-in-depth): NIP-49 backup material
// handling in the webview is confined to the identity/backup/import UI and
// its API wrappers. Anything else in `desktop/src` touching `ncryptsec` is
// structural drift toward an unguarded egress path and must be reviewed —
// the runtime guarantee lives in src-tauri's egress guard, this scan only
// keeps the blob from quietly spreading through the frontend.
//
// Mirror of the Rust-side scan in
// `src-tauri/src/egress_guard_tests.rs::ncryptsec_handling_is_confined_to_allowlisted_files`.
const SRC_ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../..",
);
const ALLOWLIST = [
"shared/api/tauriIdentity.ts",
"features/onboarding/lib/encryptedBackup.ts",
"features/onboarding/lib/encryptedBackup.test.mjs",
"features/onboarding/lib/keyImportInput.ts",
"features/onboarding/lib/keyImportInput.test.mjs",
"features/onboarding/ui/BackupStep.tsx",
"features/onboarding/ui/BackupPasswordTimeline.tsx",
"features/onboarding/ui/BackupTestFlow.tsx",
"features/onboarding/ui/EncryptedBackupCreator.tsx",
"features/onboarding/ui/NostrKeyImportForm.tsx",
"features/onboarding/ui/NsecMaskedDisplay.tsx",
"features/settings/EncryptedBackupProvider.tsx",
"features/settings/lib/encryptedBackup.ts",
"features/settings/lib/encryptedBackup.test.mjs",
"features/settings/ui/BackupTestFlow.tsx",
"features/settings/ui/EncryptedBackupCreator.tsx",
"features/settings/ui/ProfileSettingsCard.tsx",
// e2e-only mock bridge (never in the production bundle):
"testing/e2eBridge.ts",
// this scan:
"shared/lib/ncryptsecSourceScan.test.mjs",
];
function* walk(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
yield* walk(full);
} else if (/\.(ts|tsx|mjs|js|jsx)$/.test(entry.name)) {
yield full;
}
}
}
test("ncryptsec handling is confined to allowlisted frontend files", () => {
const violations = [];
for (const file of walk(SRC_ROOT)) {
const rel = path.relative(SRC_ROOT, file).replaceAll("\\", "/");
if (ALLOWLIST.includes(rel)) continue;
const content = fs.readFileSync(file, "utf8");
if (content.toLowerCase().includes("ncryptsec")) {
violations.push(rel);
}
}
assert.deepEqual(
violations,
[],
`NIP-49 material outside allowlisted files — wire it through the ` +
`identity layer (and its egress-guarded Rust commands) instead:\n` +
violations.join("\n"),
);
});
@@ -241,6 +241,54 @@
--buzz-onboarding-avatar-dialog-shadow: 0 0 0;
}
/*
* Backup options and password backup intentionally leave the bright
* onboarding world for a dark security-focused subview. Keep these semantic
* overrides on a reusable class as well as the shell so portaled
* popovers/dialogs can opt into the same treatment.
*/
.buzz-onboarding-security-theme {
color-scheme: dark;
--buzz-onboarding-shell-bottom: #082b49;
--buzz-onboarding-cta-label: #f5f5f5;
--background: 222 45% 4%;
--foreground: 0 0% 96%;
--card: 220 14% 11%;
--card-foreground: 0 0% 96%;
--popover: 220 14% 9%;
--popover-foreground: 0 0% 96%;
--primary: 0 0% 16%;
--primary-foreground: 0 0% 96%;
--secondary: 0 0% 12%;
--secondary-foreground: 0 0% 96%;
--muted: 0 0% 12%;
--muted-foreground: 0 0% 72%;
--accent: 0 0% 16%;
--accent-foreground: 0 0% 96%;
--destructive: 0 84% 70%;
--destructive-foreground: 0 0% 4%;
--border: 0 0% 22%;
--input: 0 0% 22%;
--ring: 0 0% 84%;
}
.buzz-onboarding-neutral-theme.buzz-startup-shell.buzz-onboarding-security-theme {
background-color: #010103;
background-image:
radial-gradient(circle, rgb(143 211 255 / 0.11) 1px, transparent 1px),
radial-gradient(
ellipse at 50% 58%,
rgb(28 112 196 / 0.24) 0%,
rgb(18 76 138 / 0.1) 38%,
transparent 66%
),
linear-gradient(to bottom, #010103 0%, #040914 46%, #082b49 100%);
background-size:
24px 24px,
auto,
auto;
}
.buzz-onboarding-key-text {
@apply w-full break-all [overflow-wrap:anywhere] font-mono text-nsec-key;
@@ -376,10 +424,13 @@
animation-name: buzz-onboarding-line-slide-backward;
}
.buzz-onboarding-transition-line[data-onboarding-effect="mask-reveal-down"],
.buzz-onboarding-transition-line[data-onboarding-effect="mask-reveal-up"] {
overflow: visible;
}
.buzz-onboarding-transition-line[data-onboarding-effect="mask-reveal-down"]
> .buzz-onboarding-transition-content,
.buzz-onboarding-transition-line[data-onboarding-effect="mask-reveal-up"]
> .buzz-onboarding-transition-content {
/* `backwards` (not `both`): the reveal ends on an identity transform, so
@@ -387,12 +438,23 @@
that establishes a containing block and traps `position: fixed`
descendants (the bottom-docked onboarding footer). Reverting to no
transform at rest looks identical and frees fixed positioning. */
animation: buzz-onboarding-mask-reveal-up 760ms
cubic-bezier(0.22, 1, 0.36, 1) backwards;
animation-duration: 760ms;
animation-fill-mode: backwards;
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
animation-delay: var(--buzz-onboarding-transition-delay, 0ms);
transform-origin: 50% 70%;
}
.buzz-onboarding-transition-line[data-onboarding-effect="mask-reveal-down"]
> .buzz-onboarding-transition-content {
animation-name: buzz-onboarding-mask-reveal-down;
}
.buzz-onboarding-transition-line[data-onboarding-effect="mask-reveal-up"]
> .buzz-onboarding-transition-content {
animation-name: buzz-onboarding-mask-reveal-up;
}
.buzz-onboarding-name-placeholder-caret {
animation: buzz-onboarding-caret-blink 1.1s steps(1, end) infinite;
}
@@ -543,6 +605,25 @@
}
}
@keyframes buzz-onboarding-mask-reveal-down {
from {
filter: blur(8px);
opacity: 0;
transform: translate3d(0, -30px, 0) scale(0.985);
}
56% {
filter: blur(2px);
opacity: 0.86;
}
to {
filter: blur(0);
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.buzz-onboarding-runtime-check,
.buzz-onboarding-runtime-checkmark {
+54 -15
View File
@@ -5,6 +5,11 @@ import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/shared/lib/cn";
import { buttonVariants } from "@/shared/ui/button";
import {
type CardTextureSize,
type CardTextureTone,
texturedSurfaceClasses,
} from "@/shared/ui/card";
import { MODAL_BACKDROP_BLUR_CLASS } from "@/shared/ui/modalBackdrop";
import {
MODAL_CONTENT_MOTION_CLASS,
@@ -31,25 +36,59 @@ const AlertDialogOverlay = React.forwardRef<
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
type AlertDialogContentProps = React.ComponentPropsWithoutRef<
typeof AlertDialogPrimitive.Content
> & {
surface?: "default" | "textured";
textureSize?: CardTextureSize;
textureTone?: CardTextureTone;
};
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<div className="fixed inset-0 z-50 grid place-items-center overflow-y-auto p-4 pointer-events-none">
<AlertDialogPrimitive.Content
AlertDialogContentProps
>(
(
{
className,
surface = "default",
textureSize = "regular",
textureTone = "light",
...props
},
ref,
) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<div
className={cn(
"pointer-events-auto grid w-[calc(100vw-2rem)] max-w-md gap-4 rounded-3xl bg-background p-6 shadow-2xl outline-hidden",
MODAL_CONTENT_MOTION_CLASS,
className,
"pointer-events-none fixed inset-0 z-50 grid place-items-center overflow-y-auto",
surface === "textured"
? textureSize === "compact"
? "p-10"
: "p-28 max-sm:p-18"
: "p-4",
)}
ref={ref}
{...props}
/>
</div>
</AlertDialogPortal>
));
>
<AlertDialogPrimitive.Content
className={cn(
"pointer-events-auto grid w-[calc(100vw-2rem)] max-w-md gap-4 outline-hidden",
surface === "default" && "rounded-3xl bg-background p-6 shadow-2xl",
surface === "textured" &&
texturedSurfaceClasses({
size: textureSize,
tone: textureTone,
}),
MODAL_CONTENT_MOTION_CLASS,
className,
)}
ref={ref}
{...props}
/>
</div>
</AlertDialogPortal>
),
);
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({
Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

+41 -6
View File
@@ -7,6 +7,11 @@
* fixed and repeats edge texture while the white center fills any card size.
*/
.buzz-card-textured {
--buzz-card-textured-source: url("./assets/card-texture.png");
--buzz-card-textured-slice: 416;
--buzz-card-textured-width: 208px;
--buzz-card-textured-outset: 96px;
--buzz-card-textured-min-size: 224px;
/*
* The asset has three zones, from outside in:
* 1. transparent powder bleed (outside the layout box, via outset)
@@ -31,11 +36,41 @@
* into a smaller height with `--buzz-card-textured-min-height` when the
* center seam is acceptable for that deliberately compressed surface.
*/
min-height: var(--buzz-card-textured-min-height, 224px);
min-width: 224px;
border-image-source: url("./assets/card-texture.png");
border-image-slice: 416 fill;
border-image-width: 208px;
border-image-outset: 96px;
min-height: var(
--buzz-card-textured-min-height,
var(--buzz-card-textured-min-size)
);
min-width: var(
--buzz-card-textured-min-width,
var(--buzz-card-textured-min-size)
);
border-image-source: var(--buzz-card-textured-source);
border-image-slice: var(--buzz-card-textured-slice) fill;
border-image-width: var(--buzz-card-textured-width);
border-image-outset: var(--buzz-card-textured-outset);
border-image-repeat: repeat;
}
/* Dark backup surfaces use a separately baked fill so their content stays
* bright; applying `filter: brightness()` to the element would dim children. */
.buzz-card-textured-dark {
--buzz-card-textured-source: url("./assets/card-texture-dark.png");
}
/*
* Popovers, confirmations, and compact callouts need the same powder edge
* without the full card's 96px bleed or 224px floor. This independently baked
* nine-slice keeps the edge legible at overlay scale.
*/
.buzz-card-textured-compact {
--buzz-card-textured-source: url("./assets/card-texture-compact.png");
--buzz-card-textured-slice: 136;
--buzz-card-textured-width: 68px;
--buzz-card-textured-outset: 24px;
--buzz-card-textured-safe-inset: 1.75rem;
--buzz-card-textured-min-size: 136px;
}
.buzz-card-textured-dark.buzz-card-textured-compact {
--buzz-card-textured-source: url("./assets/card-texture-dark-compact.png");
}
+43 -3
View File
@@ -5,6 +5,26 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/shared/lib/cn";
import "./card-texture.css";
export type CardTextureTone = "light" | "dark";
export type CardTextureSize = "regular" | "compact";
export const TEXTURED_SURFACE_CLASS =
"buzz-card-textured relative isolate rounded-none border-0 bg-transparent p-[var(--buzz-card-textured-safe-inset)] shadow-none";
export function texturedSurfaceClasses({
size = "regular",
tone = "light",
}: {
size?: CardTextureSize;
tone?: CardTextureTone;
} = {}): string {
return cn(
TEXTURED_SURFACE_CLASS,
tone === "dark" && "buzz-card-textured-dark",
size === "compact" && "buzz-card-textured-compact",
);
}
/**
* `variant="textured"` renders the baked nine-slice powder texture
* (`card-texture.css`). The asset bakes the card surface INTO the image:
@@ -37,7 +57,7 @@ const cardVariants = cva("text-card-foreground", {
// card-texture.css); when that floor stretches the card beyond its
// content, the content stays vertically centered instead of pinning
// to the top padding edge.
"buzz-card-textured relative isolate flex flex-col justify-center rounded-none border-0 p-[var(--buzz-card-textured-safe-inset)] shadow-none",
`${TEXTURED_SURFACE_CLASS} flex flex-col justify-center`,
},
},
defaultVariants: {
@@ -49,15 +69,35 @@ export interface CardProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof cardVariants> {
asChild?: boolean;
textureSize?: CardTextureSize;
textureTone?: CardTextureTone;
}
const Card = React.forwardRef<HTMLDivElement, CardProps>(
({ asChild = false, className, variant, ...props }, ref) => {
(
{
asChild = false,
className,
textureSize = "regular",
textureTone = "light",
variant,
...props
},
ref,
) => {
const Comp = asChild ? Slot : "div";
return (
<Comp
ref={ref}
className={cn(cardVariants({ variant, className }))}
className={cn(
cardVariants({ variant, className }),
variant === "textured" &&
textureTone === "dark" &&
"buzz-card-textured-dark",
variant === "textured" &&
textureSize === "compact" &&
"buzz-card-textured-compact",
)}
{...props}
/>
);
+54 -19
View File
@@ -2,6 +2,11 @@ import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/shared/lib/cn";
import {
type CardTextureSize,
type CardTextureTone,
texturedSurfaceClasses,
} from "@/shared/ui/card";
import {
POPOVER_RADIX_MOTION_CLASS,
POPOVER_RADIX_SIDE_MOTION_CLASS,
@@ -15,27 +20,57 @@ const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverAnchor = PopoverPrimitive.Anchor;
type PopoverContentProps = React.ComponentPropsWithoutRef<
typeof PopoverPrimitive.Content
> & {
surface?: "default" | "textured";
textureSize?: CardTextureSize;
textureTone?: CardTextureTone;
};
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, style, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-xl p-4 outline-hidden",
POPOVER_RADIX_MOTION_CLASS,
POPOVER_RADIX_SIDE_MOTION_CLASS,
POPOVER_SURFACE_CLASS,
className,
)}
style={{ ...POPOVER_SHADOW_STYLE, ...style }}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContentProps
>(
(
{
className,
align = "center",
sideOffset,
style,
surface = "default",
textureSize = "regular",
textureTone = "light",
...props
},
ref,
) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset ?? (surface === "textured" ? 24 : 4)}
className={cn(
"z-50 w-72 origin-(--radix-popover-content-transform-origin) outline-hidden",
POPOVER_RADIX_MOTION_CLASS,
POPOVER_RADIX_SIDE_MOTION_CLASS,
surface === "default" && cn("rounded-xl p-4", POPOVER_SURFACE_CLASS),
surface === "textured" &&
texturedSurfaceClasses({
size: textureSize,
tone: textureTone,
}),
className,
)}
style={{
...(surface === "default" ? POPOVER_SHADOW_STYLE : {}),
...style,
}}
{...props}
/>
</PopoverPrimitive.Portal>
),
);
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
+56 -9
View File
@@ -1,7 +1,7 @@
import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js";
import { emit } from "@tauri-apps/api/event";
import { mockIPC, mockWindows } from "@tauri-apps/api/mocks";
import { decode, npubEncode } from "nostr-tools/nip19";
import { decode, npubEncode, nsecEncode } from "nostr-tools/nip19";
import { finalizeEvent, getPublicKey } from "nostr-tools/pure";
import { parse as yamlParse } from "yaml";
import {
@@ -7229,6 +7229,22 @@ let nsecCallCount = 0;
let backupVerificationCallCount = 0;
let backupSaveCallCount = 0;
const MOCK_NCRYPTSEC =
"ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p";
const MOCK_BACKUP_PASSPHRASE = "mock horse battery staple lake orbit";
const MOCK_PASSPHRASE_WORDS = [
"mock",
"horse",
"battery",
"staple",
"lake",
"orbit",
"cedar",
"plume",
"raven",
"tundra",
];
// Per-page explicit catalog publication outcomes.
let personaSharePublicationCallCount = 0;
@@ -9939,14 +9955,22 @@ export function maybeInstallE2eTauriMocks() {
// harness there is nothing to wipe; resolving is enough — specs
// assert invocation via __BUZZ_E2E_COMMANDS__ and the pending UI.
return;
case "generate_backup_passphrase":
return "correct horse battery staple";
case "generate_backup_passphrase": {
const request = payload as {
words?: number;
separator?: string;
} | null;
const wordCount = Math.min(Math.max(request?.words ?? 3, 3), 10);
return MOCK_PASSPHRASE_WORDS.slice(0, wordCount).join(
request?.separator ?? " ",
);
}
case "create_ncryptsec_backup": {
const delayMs = activeConfig?.mock?.backupEncryptionDelayMs ?? 0;
if (delayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
return "ncryptsec1mockbackupmaterial";
return MOCK_NCRYPTSEC;
}
case "save_ncryptsec_copy": {
const paths = activeConfig?.mock?.backupSavePaths ?? [
@@ -9957,7 +9981,16 @@ export function maybeInstallE2eTauriMocks() {
return paths[index];
}
case "verify_ncryptsec_backup": {
const errors = activeConfig?.mock?.backupVerificationErrors ?? [null];
const request = payload as { password?: string } | null;
const configuredErrors = activeConfig?.mock?.backupVerificationErrors;
const hasConfiguredResult = Boolean(
activeConfig?.mock?.backupVerificationPubkeys,
);
const errors = configuredErrors ?? [
hasConfiguredResult || request?.password === MOCK_BACKUP_PASSPHRASE
? null
: "wrong backup password or damaged key backup",
];
const index = Math.min(backupVerificationCallCount, errors.length - 1);
const error = errors[index];
if (error) {
@@ -10008,12 +10041,26 @@ export function maybeInstallE2eTauriMocks() {
locked: false,
};
}
case "import_identity":
case "import_identity": {
const request = payload as { nsec?: string; password?: string } | null;
const input = request?.nsec ?? "";
if (input.trim().startsWith("ncryptsec1")) {
if (
input.trim() !== MOCK_NCRYPTSEC ||
request?.password !== MOCK_BACKUP_PASSPHRASE
) {
throw new Error("Wrong backup password or damaged key backup.");
}
mockIdentityLostCleared = true;
mockIdentityLockedCleared = true;
return importMockIdentity(
nsecEncode(hexToBytes(DEFAULT_REAL_IDENTITY.privateKey)),
);
}
mockIdentityLostCleared = true;
mockIdentityLockedCleared = true;
return importMockIdentity(
(payload as { nsec?: string } | null)?.nsec ?? "",
);
return importMockIdentity(input);
}
case "validate_repos_dir":
// The browser harness has no host filesystem to validate. Treat the
// seeded empty/default path as valid so Add Community can continue to
+4 -5
View File
@@ -21,6 +21,7 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { passThroughBackupStep } from "../helpers/onboarding";
// ── Shared catalog fixtures ───────────────────────────────────────────────────
@@ -668,12 +669,10 @@ test("onboarding setup More-harnesses click navigates to Settings → Agents", a
});
await page.goto("/");
// Reach the setup page: create a new identity key → skip backup step.
// Reach setup by creating a new identity key and continuing past the
// created-key page without opening the optional backup options.
await page.getByRole("button", { name: "Create a new identity key" }).click();
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible({
timeout: 10_000,
});
await page.getByTestId("onboarding-next").click();
await passThroughBackupStep(page);
// Now on the setup page.
await expect(
+293 -48
View File
@@ -1,6 +1,11 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import { waitForAnimations } from "../helpers/animations";
import {
dropFileOnTestId,
endWindowFileDrag,
startWindowFileDrag,
} from "../helpers/fileDrag";
async function enterMachineBackup(page: import("@playwright/test").Page) {
await installMockBridge(page, undefined, {
@@ -11,72 +16,314 @@ async function enterMachineBackup(page: import("@playwright/test").Page) {
await page.getByRole("button", { name: "Create a new identity key" }).click();
}
async function openBackupOptions(page: import("@playwright/test").Page) {
await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0);
await page.getByTestId("backup-options-link").click();
await expect(
page.getByTestId("onboarding-page-backup-options"),
).toBeVisible();
}
async function openPasswordBackup(page: import("@playwright/test").Page) {
await openBackupOptions(page);
await page.getByTestId("backup-option-password").click();
await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
}
async function invokedCommands(page: import("@playwright/test").Page) {
return page.evaluate(
() =>
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
.__BUZZ_E2E_COMMANDS__ ?? [],
);
}
const SHOTS = "test-results/screenshots-onboarding";
// Mirrors the mock bridge's MOCK_NCRYPTSEC (e2eBridge.ts): the blob the
// mocked `create_ncryptsec_backup` returns, i.e. the "downloaded file"
// contents the test-your-backup dropzone expects.
const MOCK_NCRYPTSEC =
"ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p";
test("backup step appears on fresh-key path after profile submit", async ({
page,
}) => {
await enterMachineBackup(page);
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
// Perceived-loading intro: the animated logo and "Creating" title show
// first, then the finished state replaces them after the hold.
await expect(
page.getByRole("heading", { name: "Creating your identity key" }),
).toBeVisible();
await expect(page.getByTestId("backup-intro-logo")).toBeVisible();
await expect(
page.getByRole("heading", {
name: "Your unique identity key has been created",
}),
).toBeVisible();
await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0);
});
test("backup step shows masked nsec from mock bridge", async ({ page }) => {
// ---------------------------------------------------------------------------
// Key-created view: masked key with reveal toggle. Backup options open the
// dark security view; the raw key is fetched only on explicit reveal/copy.
// ---------------------------------------------------------------------------
test("key view reveals explicitly; options copy explicitly", async ({
page,
}) => {
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
await enterMachineBackup(page);
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
const nsecDisplay = page.getByTestId("nsec-value");
await expect(nsecDisplay).toBeVisible();
await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0);
// Should start masked (blurred) — reveal button exists and eye icon visible.
const revealBtn = page.getByTestId("nsec-reveal-toggle");
await expect(revealBtn).toBeVisible();
await expect(nsecDisplay).toHaveCSS("filter", /blur/);
// Masked by default: decorative mask only, no key material in the DOM.
const key = page.getByTestId("backup-key-value");
await expect(key).toBeVisible();
await expect(key).toHaveClass(/blur/);
await expect(key).not.toContainText("nsec1");
expect(await invokedCommands(page)).not.toContain("get_nsec");
// Reveal fetches the key; box must not reflow (same-length monospace mask).
await page.getByTestId("backup-key-reveal-toggle").click();
await expect(key).toContainText("nsec1mock");
await expect(key).toHaveClass(/select-text/);
// Take a screenshot of the masked state. Capture the whole viewport: the CTAs
// are portaled into the docked footer outside the step subtree.
await waitForAnimations(page);
await page.screenshot({
path: `${SHOTS}/02-backup-step-masked.png`,
});
await page.screenshot({ path: `${SHOTS}/02-backup-chooser-revealed.png` });
// Reveal and verify the mock nsec appears.
await revealBtn.click();
await expect(nsecDisplay).not.toHaveCSS("filter", /blur/);
await expect(nsecDisplay).toContainText("nsec1mock");
// Hide again.
await page.getByTestId("backup-key-reveal-toggle").click();
await expect(key).not.toContainText("nsec1");
// Take a screenshot of the revealed state.
await waitForAnimations(page);
await page.screenshot({
path: `${SHOTS}/03-backup-step-revealed.png`,
});
});
test("backup step Next is enabled once the key is shown", async ({ page }) => {
await enterMachineBackup(page);
// Copy is available only after opening the dark backup-options view.
await page.getByTestId("backup-options-link").click();
await expect(
page.getByTestId("onboarding-page-backup-options"),
).toBeVisible();
await expect(page.getByTestId("onboarding-next")).toHaveCount(0);
await page.getByTestId("backup-copy-key").click();
await expect
.poll(async () => invokedCommands(page))
.toContain("copy_text_to_clipboard");
expect(await invokedCommands(page)).toContain("get_nsec");
// Return restores the yellow key-created view; its Next skips backup and
// continues directly to setup.
await page.getByTestId("backup-return-to-onboarding").click();
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
await expect(page.getByTestId("nsec-value")).toBeVisible();
await expect(page.getByTestId("onboarding-next")).toBeEnabled();
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
});
test("backup step advances to machine setup on Next click", async ({
// ---------------------------------------------------------------------------
// Encrypted download path ("Backup your key" step): password → encrypt
// locally → native save → saved confirmation. The raw key must never be
// fetched on this path.
// ---------------------------------------------------------------------------
test("download happy path: generated password, encrypt, native save, Next", async ({
page,
}) => {
await enterMachineBackup(page);
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
await expect(page.getByTestId("nsec-value")).toBeVisible();
await page.getByTestId("onboarding-next").click();
// Password backup opens from the dark options state without adding an
// onboarding progress step.
await openPasswordBackup(page);
// The password field starts empty; the create button sits in the footer's
// primary slot and stays disabled until a valid password exists.
const input = page.getByTestId("backup-passphrase-input");
await expect(input).toHaveValue("");
await expect(page.getByTestId("encrypted-backup-create")).toBeDisabled();
await expect(page.getByTestId("onboarding-next")).toHaveCount(0);
await expect(page.getByTestId("backup-return-to-onboarding")).toBeVisible();
const passwordPanel = page.getByTestId("backup-password-panel");
await expect(passwordPanel).toBeVisible();
await expect(passwordPanel).not.toHaveClass(/buzz-card-textured/);
await expect(passwordPanel).toHaveCSS("padding-left", "24px");
// The inset refresh icon opens the generator popover and immediately
// fills the field (mock default: 3 words, spaces).
await page.getByTestId("backup-passphrase-generate").click();
await expect(input).toHaveValue("mock horse battery");
const generatorPopover = page.getByRole("dialog");
await expect(generatorPopover).toBeVisible();
await expect(generatorPopover).not.toHaveClass(/buzz-card-textured/);
// Popover controls regenerate in place: word count (slider) and separator.
await page.getByTestId("backup-passphrase-words").focus();
await page.keyboard.press("ArrowRight");
await expect(input).toHaveValue("mock horse battery staple");
await page
.getByTestId("backup-passphrase-separator")
.selectOption({ label: "Hyphens" });
await expect(input).toHaveValue("mock-horse-battery-staple");
// Clicking the inset icon again re-rolls without closing the popover.
await page.getByTestId("backup-passphrase-generate").click();
await expect(page.getByTestId("backup-passphrase-separator")).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/03-backup-download-passphrase.png` });
// Encryption may still be running when the user commits the download. The
// explicit click queues the native save without exposing the password or
// fetching the raw key.
await page.keyboard.press("Escape");
await expect(page.getByTestId("backup-passphrase-separator")).toHaveCount(0);
// Saving commits the encrypted payload only after this explicit action.
await page.getByTestId("encrypted-backup-create").click();
// Only a successful save (the mock "picks" a path) advances to the
// "Optionally, test your backup" flow: a select-file button for the saved file
// (a composer-style drop overlay takes over the card while a file drag is
// over the window), then the password to unlock it.
await expect(
page.getByRole("heading", { name: "Optionally, test your backup" }),
).toBeVisible();
const dropzone = page.getByTestId("backup-test-dropzone");
await expect(dropzone).toBeVisible();
await expect(
page.getByRole("button", { name: "Re-download backup" }),
).toBeVisible();
// The optional security subview has no onboarding Next action. Returning to
// the yellow key view is the single exit throughout the ceremony.
await expect(page.getByTestId("onboarding-next")).toHaveCount(0);
await expect(page.getByTestId("backup-return-to-onboarding")).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/04-backup-test-dropzone.png` });
// A wrong file is rejected with an inline error; the dropzone stays.
await page.getByTestId("backup-test-file-input").setInputFiles({
name: "notes.txt",
mimeType: "text/plain",
buffer: Buffer.from("not a key backup"),
});
await expect(page.getByTestId("backup-test-error")).toBeVisible();
// A file drag over the window swaps in the drop overlay; leaving without
// dropping restores the select button.
const dropOverlay = page.getByTestId("backup-test-drop-overlay");
await startWindowFileDrag(page);
await expect(dropOverlay).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/04b-backup-test-drop-overlay.png` });
await endWindowFileDrag(page);
await expect(dropOverlay).toHaveCount(0);
// Dropping the freshly downloaded file on the overlay advances to the
// password check.
await startWindowFileDrag(page);
await expect(dropOverlay).toBeVisible();
await dropFileOnTestId(page, "backup-test-drop-overlay", MOCK_NCRYPTSEC);
const password = page.getByTestId("backup-test-password");
await expect(password).toBeVisible();
await expect(dropOverlay).toHaveCount(0);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/05-backup-test-password.png` });
// Verification is explicit and clears every submitted attempt.
await password.fill("mock-horse-battery-staplX");
await page.getByTestId("backup-test-verify").click();
await expect(page.getByTestId("backup-test-error")).toBeVisible();
await expect(password).toHaveValue("");
await password.fill("mock horse battery staple lake orbit");
await page.getByTestId("backup-test-verify").click();
await expect(page.getByTestId("backup-test-success")).toBeVisible();
// The celebration is driven by motion's rAF loop, which
// `waitForAnimations` (WAAPI-only) cannot observe — hold until the badge
// and copy have faded in before capturing.
await page.waitForTimeout(1200);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOTS}/06-backup-test-success.png` });
// The download path must never have fetched the raw key.
const commands = await invokedCommands(page);
expect(commands).not.toContain("get_nsec");
expect(commands).toContain("create_ncryptsec_backup");
// Completion remains inside the optional security subview. Return to the
// yellow key view, whose standard Next action continues onboarding.
await expect(page.getByTestId("onboarding-next")).toHaveCount(0);
await page.getByTestId("backup-return-to-onboarding").click();
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
await expect(page.getByTestId("onboarding-next")).toBeEnabled();
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
});
test("security view returns to the yellow onboarding view", async ({
page,
}) => {
await enterMachineBackup(page);
await openPasswordBackup(page);
await expect(page.getByTestId("backup-passphrase-input")).toBeVisible();
await expect(page.getByTestId("onboarding-step-dots")).toHaveCount(0);
await expect(page.getByTestId("onboarding-next")).toHaveCount(0);
await page.getByTestId("backup-return-to-onboarding").click();
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
await expect(page.getByTestId("backup-key-value")).toBeVisible();
await expect(page.getByTestId("onboarding-next")).toBeVisible();
});
test("returning to onboarding resets password-backup progress", async ({
page,
}) => {
await enterMachineBackup(page);
await openPasswordBackup(page);
const input = page.getByTestId("backup-passphrase-input");
await input.fill("mock-horse-battery-staple");
await page.getByTestId("encrypted-backup-create").click();
await expect(
page.getByRole("heading", { name: "Optionally, test your backup" }),
).toBeVisible();
await page.getByTestId("backup-return-to-onboarding").click();
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
// Re-entering the security flow intentionally starts a fresh optional
// backup session so no password or completed state leaks across navigation.
await openPasswordBackup(page);
await expect(
page.getByRole("heading", { name: "Backup your key with a password" }),
).toBeVisible();
await expect(page.getByTestId("backup-passphrase-input")).toHaveValue("");
await expect(page.getByTestId("encrypted-backup-create")).toBeDisabled();
});
test("typed password requires 12 characters", async ({ page }) => {
await enterMachineBackup(page);
await openPasswordBackup(page);
const create = page.getByTestId("encrypted-backup-create");
await expect(create).toBeDisabled(); // empty field
await page.getByTestId("backup-passphrase-input").fill("short");
await expect(page.getByTestId("backup-passphrase-issue")).toBeVisible();
await expect(create).toBeDisabled();
await page
.getByTestId("backup-passphrase-input")
.fill("a much longer passphrase");
await expect(page.getByTestId("backup-passphrase-issue")).toHaveCount(0);
await expect(create).toBeEnabled();
});
test("backup step back button returns to machine identity choice", async ({
page,
}) => {
@@ -96,10 +343,10 @@ test("backup step back button returns to machine identity choice", async ({
});
// ---------------------------------------------------------------------------
// B4: Error path coverage
// B4: Error path coverage (reveal/copy)
// ---------------------------------------------------------------------------
test("backup step shows error banner and retry button when get_nsec fails", async ({
test("reveal shows inline error when get_nsec fails and Next still advances", async ({
page,
}) => {
await installMockBridge(
@@ -111,20 +358,17 @@ test("backup step shows error banner and retry button when get_nsec fails", asyn
await page.getByRole("button", { name: "Create a new identity key" }).click();
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
await expect(page.getByTestId("backup-load-error")).toBeVisible();
await expect(page.getByTestId("backup-retry")).toBeVisible();
// Next is blocked on error; Skip for now ghost is shown instead.
await expect(page.getByTestId("onboarding-next")).toBeDisabled();
await expect(page.getByTestId("backup-skip")).toBeVisible();
await page.getByTestId("backup-key-reveal-toggle").click();
// Skip for now still advances to machine setup.
await page.getByTestId("backup-skip").click();
await expect(page.getByTestId("backup-copy-error")).toBeVisible();
// Keychain failure does not trap the user: Next still skips backup and
// advances directly to setup.
await expect(page.getByTestId("onboarding-next")).toBeEnabled();
await page.getByTestId("onboarding-next").click();
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
});
test("backup step retry succeeds and shows key after initial failure", async ({
page,
}) => {
test("reveal retry succeeds after initial failure", async ({ page }) => {
// First call fails, second succeeds (sequenced via nsecErrors).
await installMockBridge(
page,
@@ -134,10 +378,11 @@ test("backup step retry succeeds and shows key after initial failure", async ({
await page.goto("/");
await page.getByRole("button", { name: "Create a new identity key" }).click();
await expect(page.getByTestId("backup-load-error")).toBeVisible();
await page.getByTestId("backup-key-reveal-toggle").click();
await expect(page.getByTestId("backup-copy-error")).toBeVisible();
// Retry — second call succeeds.
await page.getByTestId("backup-retry").click();
await expect(page.getByTestId("nsec-value")).toBeVisible();
await expect(page.getByTestId("backup-load-error")).not.toBeVisible();
// Retry — second call succeeds and clears the error.
await page.getByTestId("backup-key-reveal-toggle").click();
await expect(page.getByTestId("backup-key-value")).toContainText("nsec1mock");
await expect(page.getByTestId("backup-copy-error")).not.toBeVisible();
});
@@ -10,6 +10,8 @@ const BLANK_TYLER_IDENTITY = {
};
const SHOT_DIR = "test-results/onboarding-docked-cta";
const NCRYPTSEC =
"ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p";
test.use({ viewport: { width: 1280, height: 800 } });
@@ -46,7 +48,21 @@ test("machine onboarding: landing, backup, setup docked CTAs", async ({
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/01b-enter-key.png` });
await page.getByRole("button", { name: "Back" }).click();
await page.getByTestId("nostr-import-nsec-input").fill(NCRYPTSEC);
await expect(
page.getByRole("heading", { name: "Unlock your account" }),
).toBeVisible();
await expect(page.getByTestId("backup-password-timeline")).toBeVisible();
await expect(page.getByTestId("restore-ncryptsec-affordance")).toBeVisible();
await expect(page.getByTestId("restore-unlock-icon")).toBeVisible();
await expect(page.getByTestId("nostr-import-passphrase")).toBeFocused();
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/01c-restore-backup.png` });
// The first Back returns to key selection; the second leaves import.
await page.getByRole("button", { name: "Back", exact: true }).click();
await expect(importCard).toBeVisible();
await page.getByRole("button", { name: "Back", exact: true }).click();
await expect(
page.getByRole("button", { name: "Create a new identity key" }),
).toBeVisible();
@@ -59,12 +75,56 @@ test("machine onboarding: landing, backup, setup docked CTAs", async ({
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/02-backup.png` });
// The key stays masked behind an explicit reveal toggle.
await expect(page.getByTestId("backup-key-value")).toBeVisible();
// Reveal the key: box must not reflow (same-length monospace mask).
await page.getByTestId("nsec-reveal-toggle").click();
await expect(page.getByTestId("nsec-value")).toHaveClass(/select-text/);
await page.getByTestId("backup-key-reveal-toggle").click();
await expect(page.getByTestId("backup-key-value")).toHaveClass(/select-text/);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/02b-backup-revealed.png` });
// Backup options leave the yellow flow for the dark security view without
// adding a progress step or a generic Next action.
await page.getByTestId("backup-options-link").click();
await expect(
page.getByTestId("onboarding-page-backup-options"),
).toBeVisible();
await expect(page.getByTestId("onboarding-next")).toHaveCount(0);
const optionPanels = page.getByTestId("backup-option-panel");
await expect(optionPanels).toHaveCount(3);
await expect(
page.getByTestId("backup-options").locator(".buzz-card-textured"),
).toHaveCount(0);
await expect(optionPanels.first()).toHaveCSS("padding-left", "24px");
const titleTops = await optionPanels
.locator("span.text-lg")
.evaluateAll((titles) =>
titles.map((title) => title.getBoundingClientRect().top),
);
expect(Math.max(...titleTops) - Math.min(...titleTops)).toBeLessThan(1);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/02c-backup-options.png` });
await page.getByTestId("backup-option-password").click();
await expect(page.getByTestId("onboarding-page-download")).toBeVisible();
const passwordPanel = page.getByTestId("backup-password-panel");
await expect(passwordPanel).toBeVisible();
await expect(passwordPanel).not.toHaveClass(/buzz-card-textured/);
await expect(passwordPanel).toHaveCSS("padding-left", "24px");
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/02d-backup-password.png` });
await page.getByTestId("backup-passphrase-generate").click();
const generatorPopover = page.getByRole("dialog");
await expect(generatorPopover).toBeVisible();
await expect(generatorPopover).not.toHaveClass(/buzz-card-textured/);
await waitForAnimations(page);
await page.screenshot({ path: `${SHOT_DIR}/02e-backup-generator.png` });
await page.keyboard.press("Escape");
await page.getByTestId("backup-return-to-onboarding").click();
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
await page.getByTestId("onboarding-next").click();
await expect(
page.getByRole("heading", { name: "Set up your agent harnesses" }),
@@ -115,6 +175,39 @@ test("machine key import remains usable in a short viewport", async ({
expect(layout.scrollWidth).toBe(layout.clientWidth);
});
test("backup options keep one-column geometry on narrow windows", async ({
page,
}) => {
await page.setViewportSize({ width: 600, height: 700 });
await installMockBridge(page, undefined, {
skipCommunitySeed: true,
skipOnboardingSeed: true,
});
await page.goto("/");
await page.getByRole("button", { name: "Create a new identity key" }).click();
await expect(
page.getByRole("heading", {
name: "Your unique identity key has been created",
}),
).toBeVisible();
await page.getByTestId("backup-options-link").click();
const panels = page.getByTestId("backup-option-panel");
await expect(panels).toHaveCount(3);
const geometry = await panels.evaluateAll((elements) => ({
clientWidth: document.documentElement.clientWidth,
lefts: elements.map((element) => element.getBoundingClientRect().left),
rights: elements.map((element) => element.getBoundingClientRect().right),
scrollWidth: document.documentElement.scrollWidth,
}));
expect(new Set(geometry.lefts.map(Math.round)).size).toBe(1);
expect(geometry.lefts.every((left) => left >= 0)).toBe(true);
expect(geometry.rights.every((right) => right <= geometry.clientWidth)).toBe(
true,
);
expect(geometry.scrollWidth).toBe(geometry.clientWidth);
});
test("relay onboarding: profile and avatar docked CTAs", async ({ page }) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(page, undefined, { skipOnboardingSeed: true });
+125
View File
@@ -621,6 +621,131 @@ test("first-launch key import continues to machine setup", async ({ page }) => {
await expect(page.getByTestId("app-loading-gate")).toHaveCount(0);
});
test("first-launch encrypted backup import asks for a passphrase and continues", async ({
page,
}) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await installMockBridge(page, undefined, {
skipCommunitySeed: true,
skipOnboardingSeed: true,
});
await page.goto("/");
await page.getByRole("button", { name: "Use an existing key" }).click();
// Spec-vector blob the mock bridge accepts with the mock passphrase.
const mockNcryptsec =
"ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p";
await page
.getByTestId("nostr-import-nsec-input")
.fill(mockNcryptsec.slice(0, -1));
await expect(
page.getByRole("heading", { name: "Enter your private key" }),
).toBeVisible();
await expect(page.getByTestId("nostr-import-passphrase")).toHaveCount(0);
await page
.getByTestId("nostr-import-nsec-input")
.pressSequentially(mockNcryptsec.slice(-1));
// A complete, checksummed NIP-49 value advances immediately without
// submitting. The password stage updates its copy, illustration, and focus.
await expect(
page.getByRole("heading", { name: "Unlock your account" }),
).toBeVisible();
await expect(page.getByTestId("backup-password-timeline")).toBeVisible();
await expect(page.getByTestId("restore-ncryptsec-affordance")).toBeVisible();
await expect(page.getByTestId("restore-unlock-icon")).toBeVisible();
await expect(page.getByTestId("nostr-import-card")).toHaveCount(0);
await expect(page.getByTestId("nostr-import-file-button")).toHaveCount(0);
await expect(page.getByTestId("nostr-import-passphrase")).toBeFocused();
await expect(page.getByTestId("nostr-import-submit")).toBeDisabled();
// Wrong passphrase surfaces the decrypt error and stays on the form.
await page.getByTestId("nostr-import-passphrase").fill("wrong passphrase");
await page.getByTestId("nostr-import-submit").click();
await expect(page.getByTestId("nostr-import-feedback")).toContainText(
/wrong backup password/i,
);
await page
.getByTestId("nostr-import-passphrase")
.fill("mock horse battery staple lake orbit");
await page.getByTestId("nostr-import-submit").click();
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
await expect(page.getByTestId("machine-onboarding-gate")).toBeVisible();
});
test("first-launch import accepts an .ncryptsec backup file", async ({
page,
}) => {
await installMockBridge(page, undefined, {
skipCommunitySeed: true,
skipOnboardingSeed: true,
});
await page.goto("/");
await page.getByRole("button", { name: "Use an existing key" }).click();
// The spotlight variant must expose a file path: a wiped user returns with
// exactly the identity.ncryptsec our own save dialog produced. The accept
// attribute is asserted explicitly because setInputFiles bypasses it — the
// OS picker is what filters on it in real use.
await expect(page.getByTestId("nostr-import-file-button")).toBeVisible();
const fileInput = page.getByTestId("nostr-import-file-input");
await expect(fileInput).toHaveAttribute(
"accept",
".key,.ncryptsec,text/plain",
);
await fileInput.setInputFiles({
buffer: Buffer.alloc(1_025, "x"),
mimeType: "text/plain",
name: "not-a-backup.txt",
});
await expect(page.getByTestId("nostr-import-feedback")).toContainText(
/too large to be a key backup/i,
);
// Spec-vector blob the mock bridge accepts with the mock passphrase.
const mockNcryptsec =
"ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p";
await fileInput.setInputFiles({
buffer: Buffer.from(`${mockNcryptsec}\n`),
mimeType: "text/plain",
name: "identity.ncryptsec",
});
// File contents advance to the same focused password stage as manual input.
await expect(
page.getByRole("heading", { name: "Unlock your account" }),
).toBeVisible();
await expect(page.getByTestId("backup-password-timeline")).toBeVisible();
await expect(page.getByTestId("nostr-import-passphrase")).toBeFocused();
// Back first returns to key/file selection instead of leaving import.
await page.getByRole("button", { name: "Back", exact: true }).click();
await expect(
page.getByRole("heading", { name: "Enter your private key" }),
).toBeVisible();
await expect(page.getByTestId("nostr-import-card")).toBeVisible();
await expect(page.getByTestId("nostr-import-file-button")).toBeVisible();
await expect(page.getByTestId("nostr-import-nsec-input")).toHaveValue("");
await fileInput.setInputFiles({
buffer: Buffer.from(`${mockNcryptsec}\n`),
mimeType: "text/plain",
name: "identity.ncryptsec",
});
await page
.getByTestId("nostr-import-passphrase")
.fill("mock horse battery staple lake orbit");
await page.getByTestId("nostr-import-submit").click();
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
await expect(page.getByTestId("machine-onboarding-gate")).toBeVisible();
});
test("non-local runtime override keeps community selection without release flag", async ({
page,
}) => {
+6 -28
View File
@@ -3,7 +3,7 @@
*
* Signing out wipes the identity key and all local data, so the dialog gates
* "Delete My Data" behind two explicit steps:
* 1. backup reveal/copy the nsec, then check "I have saved my private key"
* 1. backup check "I have saved my private key"
* 2. typed confirmation type the exact phrase "wipe all my data"
*/
import { expect, type Page, test } from "@playwright/test";
@@ -13,10 +13,6 @@ import { openSettings } from "../helpers/settings";
const CONFIRM_PHRASE = "wipe all my data";
// The mock bridge routes copy_text_to_clipboard through navigator.clipboard,
// which requires explicit permissions in headless Chromium.
test.use({ permissions: ["clipboard-read", "clipboard-write"] });
async function openSignOutDialog(page: Page) {
await openSettings(page, "profile");
const section = page.getByTestId("settings-signout");
@@ -36,12 +32,8 @@ test("delete button unlocks only after backup + typed phrase", async ({
const backupCheckbox = page.getByTestId("signout-backup-confirm");
const phraseInput = page.getByTestId("signout-confirm-phrase");
// Everything locked initially: no key interaction yet.
// Delete is locked initially; the backup checkbox is immediately usable.
await expect(deleteButton).toBeDisabled();
await expect(backupCheckbox).toBeDisabled();
// Copying the key unlocks the backup checkbox.
await page.getByTestId("nsec-copy").click();
await expect(backupCheckbox).toBeEnabled();
await backupCheckbox.click();
@@ -61,24 +53,11 @@ test("delete button unlocks only after backup + typed phrase", async ({
await expect(deleteButton).toBeDisabled();
});
test("reveal also unlocks the backup checkbox", async ({ page }) => {
await installMockBridge(page);
await page.goto("/");
await openSignOutDialog(page);
const backupCheckbox = page.getByTestId("signout-backup-confirm");
await expect(backupCheckbox).toBeDisabled();
await page.getByTestId("nsec-reveal-toggle").click();
await expect(backupCheckbox).toBeEnabled();
});
test("completing both gates invokes sign_out", async ({ page }) => {
await installMockBridge(page);
await page.goto("/");
await openSignOutDialog(page);
await page.getByTestId("nsec-copy").click();
await page.getByTestId("signout-backup-confirm").click();
await page.getByTestId("signout-confirm-phrase").fill(CONFIRM_PHRASE);
@@ -104,16 +83,15 @@ test("cancel resets the gates for the next open", async ({ page }) => {
await openSignOutDialog(page);
// Satisfy both gates, then cancel.
await page.getByTestId("nsec-copy").click();
await page.getByTestId("signout-backup-confirm").click();
await page.getByTestId("signout-confirm-phrase").fill(CONFIRM_PHRASE);
await page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByRole("alertdialog")).not.toBeVisible();
// Reopen — everything must be locked again.
// Reopen — everything must be reset again.
await page.getByTestId("signout-open-dialog").click();
await expect(page.getByRole("alertdialog")).toBeVisible();
await expect(page.getByTestId("signout-backup-confirm")).toBeDisabled();
await expect(page.getByTestId("signout-backup-confirm")).not.toBeChecked();
await expect(page.getByTestId("signout-confirm-phrase")).toHaveValue("");
await expect(page.getByTestId("signout-confirm")).toBeDisabled();
});
@@ -125,8 +103,8 @@ test("nsec load failure still allows sign-out (backup step degrades)", async ({
await page.goto("/");
await openSignOutDialog(page);
// Error shown in place of the key; checkbox is usable so the user is not
// permanently locked out of signing out.
// Error shown in place of the key; checkbox is still usable so the user is
// not locked out of signing out.
await expect(page.getByTestId("signout-nsec-error")).toContainText(
"Keychain locked",
);
+52
View File
@@ -0,0 +1,52 @@
import type { Page } from "@playwright/test";
/**
* Simulate a native file drag entering the window. The backup test flow
* listens for window-level dragenter with a "Files" payload and swaps in a
* composer-style drop overlay over its host surface.
*/
export async function startWindowFileDrag(page: Page): Promise<void> {
await page.evaluate(() => {
const dataTransfer = new DataTransfer();
dataTransfer.items.add(
new File(["x"], "identity.ncryptsec", { type: "text/plain" }),
);
window.dispatchEvent(
new DragEvent("dragenter", { dataTransfer, bubbles: true }),
);
});
}
/** Simulate the file drag leaving the window without dropping. */
export async function endWindowFileDrag(page: Page): Promise<void> {
await page.evaluate(() => {
window.dispatchEvent(new DragEvent("dragend", { bubbles: true }));
});
}
/** Drop a text file with the given contents onto the element with `testId`. */
export async function dropFileOnTestId(
page: Page,
testId: string,
contents: string,
name = "identity.ncryptsec",
): Promise<void> {
await page.evaluate(
({ testId, contents, name }) => {
const dataTransfer = new DataTransfer();
dataTransfer.items.add(
new File([contents], name, { type: "text/plain" }),
);
const target = document.querySelector(`[data-testid="${testId}"]`);
if (!target) throw new Error(`drop target ${testId} not found`);
target.dispatchEvent(
new DragEvent("drop", {
dataTransfer,
bubbles: true,
cancelable: true,
}),
);
},
{ testId, contents, name },
);
}
+1 -2
View File
@@ -16,9 +16,8 @@ export async function seedActiveIdentity(
);
}
/** Navigate through the backup step (fresh-key path). */
/** Continue past the created-key page without opening optional backup options. */
export async function passThroughBackupStep(page: Page) {
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
await expect(page.getByTestId("nsec-value")).toBeVisible();
await page.getByTestId("onboarding-next").click();
}