mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
commit
21e8e58ce8
@@ -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`);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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> =
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 wasn’t 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 wasn’t 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.
|
||||
You’ll 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 > Profile > 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 > Profile >
|
||||
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 > Profile >
|
||||
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'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
|
||||
? "That’s 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>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 }),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 |
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user