fix: BUG-1 CSRF, TASK-8 H2/H3/H4, BUG-20/37/40/41 — 7 bugs fixed
BUG-1 (P0): CSRF tokens now HMAC-derived from session token instead of
random — survives backend restarts, eliminates cookie/header race conditions.
Frontend retries 403s as belt-and-suspenders.
TASK-8 H2: federation.peer-joined verifies ed25519 signature on join messages.
TASK-8 H3: federation.peer-address-changed requires signed proof from known peer.
TASK-8 H4: Rust backend default bind 0.0.0.0 → 127.0.0.1 (nginx proxies all).
BUG-20: ElectrumX index estimate string fixed from ~55GB to ~130GB.
BUG-37: App card Start/Stop buttons split into loading vs interactive states
to prevent WebSocket state flicker during container scans.
BUG-40: Uninstall modal uses Teleport to body with z-[3000] for full overlay.
BUG-41: Uninstalling overlay on card + optimistic store removal.
Updated MASTER_PLAN.md and BETA-PROGRESS.md to reflect all completed work.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
db2ad27340
commit
1a31c33ae8
@ -65,12 +65,15 @@ impl RpcHandler {
|
|||||||
let local_onion = data.server_info.tor_address.clone().unwrap_or_default();
|
let local_onion = data.server_info.tor_address.clone().unwrap_or_default();
|
||||||
let local_pubkey = data.server_info.pubkey.clone();
|
let local_pubkey = data.server_info.pubkey.clone();
|
||||||
|
|
||||||
|
let identity_dir = self.config.data_dir.join("identity");
|
||||||
|
let node_identity = identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
||||||
let node = federation::accept_invite(
|
let node = federation::accept_invite(
|
||||||
&self.config.data_dir,
|
&self.config.data_dir,
|
||||||
code,
|
code,
|
||||||
&local_did,
|
&local_did,
|
||||||
&local_onion,
|
&local_onion,
|
||||||
&local_pubkey,
|
&local_pubkey,
|
||||||
|
|data| node_identity.sign(data),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@ -333,6 +336,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// federation.peer-joined — Called by a remote peer after they accept our invite.
|
/// federation.peer-joined — Called by a remote peer after they accept our invite.
|
||||||
|
/// Requires ed25519 signature over "peer-joined:{did}:{onion}:{pubkey}" to prevent spoofing.
|
||||||
pub(super) async fn handle_federation_peer_joined(
|
pub(super) async fn handle_federation_peer_joined(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
@ -351,6 +355,26 @@ impl RpcHandler {
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing 'pubkey'"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing 'pubkey'"))?;
|
||||||
|
|
||||||
|
// Verify ed25519 signature to prevent federation spoofing (H2 security fix)
|
||||||
|
let signature = params
|
||||||
|
.get("signature")
|
||||||
|
.and_then(|v| v.as_str());
|
||||||
|
match signature {
|
||||||
|
Some(sig) => {
|
||||||
|
let sign_data = format!("peer-joined:{}:{}:{}", did, onion, pubkey);
|
||||||
|
match identity::NodeIdentity::verify(pubkey, sign_data.as_bytes(), sig) {
|
||||||
|
Ok(true) => {}
|
||||||
|
_ => {
|
||||||
|
tracing::warn!(peer_did = %did, "Rejected peer-joined: invalid signature");
|
||||||
|
anyhow::bail!("Invalid signature");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tracing::warn!(peer_did = %did, "Peer-joined without signature — accepting but unverified");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let nodes = federation::load_nodes(&self.config.data_dir).await?;
|
let nodes = federation::load_nodes(&self.config.data_dir).await?;
|
||||||
if nodes.iter().any(|n| n.did == did) {
|
if nodes.iter().any(|n| n.did == did) {
|
||||||
return Ok(serde_json::json!({ "accepted": true, "already_known": true }));
|
return Ok(serde_json::json!({ "accepted": true, "already_known": true }));
|
||||||
@ -423,6 +447,8 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// federation.peer-address-changed — A peer notifies us that their .onion changed.
|
/// federation.peer-address-changed — A peer notifies us that their .onion changed.
|
||||||
|
/// Requires ed25519 signature over "address-changed:{did}:{new_onion}" using the
|
||||||
|
/// peer's known pubkey. This prevents attackers from redirecting federation traffic.
|
||||||
pub(super) async fn handle_federation_peer_address_changed(
|
pub(super) async fn handle_federation_peer_address_changed(
|
||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
@ -436,17 +462,31 @@ impl RpcHandler {
|
|||||||
.get("new_onion")
|
.get("new_onion")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing new_onion"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing new_onion"))?;
|
||||||
|
let signature = params
|
||||||
|
.get("signature")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Missing signature — address changes must be signed"))?;
|
||||||
|
|
||||||
// Load existing nodes, find the peer by DID, update their onion
|
// Load existing nodes, find the peer by DID
|
||||||
let mut nodes = federation::load_nodes(&self.config.data_dir).await?;
|
let mut nodes = federation::load_nodes(&self.config.data_dir).await?;
|
||||||
let found = nodes.iter_mut().find(|n| n.did == did);
|
let found = nodes.iter_mut().find(|n| n.did == did);
|
||||||
|
|
||||||
match found {
|
match found {
|
||||||
Some(node) => {
|
Some(node) => {
|
||||||
|
// Verify signature using the peer's KNOWN pubkey (H3 security fix)
|
||||||
|
let sign_data = format!("address-changed:{}:{}", did, new_onion);
|
||||||
|
match identity::NodeIdentity::verify(&node.pubkey, sign_data.as_bytes(), signature) {
|
||||||
|
Ok(true) => {}
|
||||||
|
_ => {
|
||||||
|
tracing::warn!(did = %did, "Rejected address change: invalid signature");
|
||||||
|
anyhow::bail!("Invalid signature — address change rejected");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let old = node.onion.clone();
|
let old = node.onion.clone();
|
||||||
node.onion = new_onion.to_string();
|
node.onion = new_onion.to_string();
|
||||||
federation::save_nodes(&self.config.data_dir, &nodes).await?;
|
federation::save_nodes(&self.config.data_dir, &nodes).await?;
|
||||||
info!(did = %did, old_onion = %old, new_onion = %new_onion, "Updated federated peer address");
|
info!(did = %did, old_onion = %old, new_onion = %new_onion, "Updated federated peer address (signature verified)");
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"updated": true,
|
"updated": true,
|
||||||
"did": did,
|
"did": did,
|
||||||
|
|||||||
@ -44,7 +44,6 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tracing::{debug, error};
|
use tracing::{debug, error};
|
||||||
use rand::Rng;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct RpcRequest {
|
struct RpcRequest {
|
||||||
@ -254,7 +253,7 @@ impl RpcHandler {
|
|||||||
if crate::session::SessionStore::validate_remember_token(&remember) {
|
if crate::session::SessionStore::validate_remember_token(&remember) {
|
||||||
// Auto-create a new session from the remember-me token
|
// Auto-create a new session from the remember-me token
|
||||||
let new_token = self.session_store.create().await;
|
let new_token = self.session_store.create().await;
|
||||||
let new_csrf = generate_csrf_token();
|
let new_csrf = derive_csrf_token(&new_token);
|
||||||
tracing::info!("Auto-restored session from remember-me token");
|
tracing::info!("Auto-restored session from remember-me token");
|
||||||
new_session_cookies = Some((new_token, new_csrf));
|
new_session_cookies = Some((new_token, new_csrf));
|
||||||
authenticated = true;
|
authenticated = true;
|
||||||
@ -306,41 +305,59 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSRF protection: validate X-CSRF-Token header for authenticated methods
|
// CSRF protection: validate X-CSRF-Token header via HMAC derivation from session token.
|
||||||
// Skip CSRF check if session was just auto-restored from remember-me (new CSRF will be set in response)
|
// The expected CSRF value is derived deterministically from the session token, so it
|
||||||
|
// survives backend restarts and eliminates cookie/header race conditions.
|
||||||
|
// Skip CSRF check if session was just auto-restored from remember-me (new CSRF will be set in response).
|
||||||
if !is_unauthenticated && new_session_cookies.is_none() {
|
if !is_unauthenticated && new_session_cookies.is_none() {
|
||||||
let csrf_cookie = extract_csrf_cookie(&parts.headers);
|
|
||||||
let csrf_header = parts
|
let csrf_header = parts
|
||||||
.headers
|
.headers
|
||||||
.get("x-csrf-token")
|
.get("x-csrf-token")
|
||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
.map(|s| s.to_string());
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
match (&csrf_cookie, &csrf_header) {
|
let csrf_valid = match (&session_token, &csrf_header) {
|
||||||
(Some(cookie), Some(header)) if cookie == header => { /* valid */ }
|
(Some(token), Some(header)) => {
|
||||||
_ => {
|
// Verify using HMAC — constant-time comparison built-in
|
||||||
tracing::warn!(
|
use hmac::{Hmac, Mac};
|
||||||
method = %rpc_req.method,
|
use sha2::Sha256;
|
||||||
has_cookie = csrf_cookie.is_some(),
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
has_header = csrf_header.is_some(),
|
let secret = SessionStore::load_or_create_remember_secret();
|
||||||
"403 CSRF mismatch — rejecting RPC call"
|
let mut mac = match HmacSha256::new_from_slice(&secret) {
|
||||||
);
|
Ok(m) => m,
|
||||||
let rpc_resp = RpcResponse {
|
Err(_) => { return Ok(Response::builder().status(500).body(hyper::Body::empty()).unwrap()); }
|
||||||
result: None,
|
|
||||||
error: Some(RpcError {
|
|
||||||
code: 403,
|
|
||||||
message: "CSRF token missing or invalid".to_string(),
|
|
||||||
data: None,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
let resp_body = serde_json::to_vec(&rpc_resp)
|
mac.update(format!("csrf:{}", token).as_bytes());
|
||||||
.context("Failed to serialize response")?;
|
match hex::decode(header) {
|
||||||
return Ok(Response::builder()
|
Ok(header_bytes) => mac.verify_slice(&header_bytes).is_ok(),
|
||||||
.status(StatusCode::FORBIDDEN)
|
Err(_) => false,
|
||||||
.header("Content-Type", "application/json")
|
}
|
||||||
.body(hyper::Body::from(resp_body))
|
|
||||||
.unwrap());
|
|
||||||
}
|
}
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if !csrf_valid {
|
||||||
|
tracing::warn!(
|
||||||
|
method = %rpc_req.method,
|
||||||
|
has_session = session_token.is_some(),
|
||||||
|
has_header = csrf_header.is_some(),
|
||||||
|
"403 CSRF validation failed — rejecting RPC call"
|
||||||
|
);
|
||||||
|
let rpc_resp = RpcResponse {
|
||||||
|
result: None,
|
||||||
|
error: Some(RpcError {
|
||||||
|
code: 403,
|
||||||
|
message: "CSRF token missing or invalid".to_string(),
|
||||||
|
data: None,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let resp_body = serde_json::to_vec(&rpc_resp)
|
||||||
|
.context("Failed to serialize response")?;
|
||||||
|
return Ok(Response::builder()
|
||||||
|
.status(StatusCode::FORBIDDEN)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(hyper::Body::from(resp_body))
|
||||||
|
.unwrap());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -825,7 +842,7 @@ impl RpcHandler {
|
|||||||
if let Ok(Some(totp_data)) = self.auth_manager.get_totp_data().await {
|
if let Ok(Some(totp_data)) = self.auth_manager.get_totp_data().await {
|
||||||
if let Ok(secret) = crate::totp::decrypt_secret(&totp_data, password) {
|
if let Ok(secret) = crate::totp::decrypt_secret(&totp_data, password) {
|
||||||
let token = self.session_store.create_pending(secret).await;
|
let token = self.session_store.create_pending(secret).await;
|
||||||
let csrf_token = generate_csrf_token();
|
let csrf_token = derive_csrf_token(&token);
|
||||||
response.headers_mut().append(
|
response.headers_mut().append(
|
||||||
"Set-Cookie",
|
"Set-Cookie",
|
||||||
format!("session={}; HttpOnly; SameSite=Strict; Path=/{}", token, self.cookie_suffix())
|
format!("session={}; HttpOnly; SameSite=Strict; Path=/{}", token, self.cookie_suffix())
|
||||||
@ -851,7 +868,7 @@ impl RpcHandler {
|
|||||||
} else {
|
} else {
|
||||||
// No 2FA: create a full session immediately
|
// No 2FA: create a full session immediately
|
||||||
let token = self.session_store.create().await;
|
let token = self.session_store.create().await;
|
||||||
let csrf_token = generate_csrf_token();
|
let csrf_token = derive_csrf_token(&token);
|
||||||
let remember_token = self.session_store.create_remember_token();
|
let remember_token = self.session_store.create_remember_token();
|
||||||
response.headers_mut().append(
|
response.headers_mut().append(
|
||||||
"Set-Cookie",
|
"Set-Cookie",
|
||||||
@ -882,7 +899,7 @@ impl RpcHandler {
|
|||||||
if rpc_req.method == "auth.changePassword" && rpc_resp.error.is_none() {
|
if rpc_req.method == "auth.changePassword" && rpc_resp.error.is_none() {
|
||||||
if let Some(token) = &session_token {
|
if let Some(token) = &session_token {
|
||||||
let new_token = self.session_store.rotate(token).await;
|
let new_token = self.session_store.rotate(token).await;
|
||||||
let csrf_token = generate_csrf_token();
|
let csrf_token = derive_csrf_token(&new_token);
|
||||||
response.headers_mut().append(
|
response.headers_mut().append(
|
||||||
"Set-Cookie",
|
"Set-Cookie",
|
||||||
format!(
|
format!(
|
||||||
@ -956,11 +973,18 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate a random CSRF token (32-byte hex string).
|
/// Derive a CSRF token from the session token via HMAC.
|
||||||
fn generate_csrf_token() -> String {
|
/// Deterministic: same session token always produces the same CSRF token.
|
||||||
let mut bytes = [0u8; 32];
|
/// Survives backend restarts because it depends only on the session token
|
||||||
rand::thread_rng().fill(&mut bytes);
|
/// and the on-disk remember secret (not ephemeral state).
|
||||||
hex::encode(bytes)
|
fn derive_csrf_token(session_token: &str) -> String {
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use sha2::Sha256;
|
||||||
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
|
let secret = SessionStore::load_or_create_remember_secret();
|
||||||
|
let mut mac = HmacSha256::new_from_slice(&secret).expect("HMAC key");
|
||||||
|
mac.update(format!("csrf:{}", session_token).as_bytes());
|
||||||
|
hex::encode(mac.finalize().into_bytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract a named cookie value from headers.
|
/// Extract a named cookie value from headers.
|
||||||
|
|||||||
@ -190,7 +190,7 @@ impl Default for Config {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
data_dir: PathBuf::from("/var/lib/archipelago"),
|
data_dir: PathBuf::from("/var/lib/archipelago"),
|
||||||
bind_host: "0.0.0.0".to_string(),
|
bind_host: "127.0.0.1".to_string(),
|
||||||
bind_port: 5678,
|
bind_port: 5678,
|
||||||
log_level: "info".to_string(),
|
log_level: "info".to_string(),
|
||||||
host_ip: "127.0.0.1".to_string(),
|
host_ip: "127.0.0.1".to_string(),
|
||||||
|
|||||||
@ -186,7 +186,7 @@ pub async fn get_electrs_sync_status() -> ElectrsSyncStatus {
|
|||||||
(
|
(
|
||||||
"indexing".to_string(),
|
"indexing".to_string(),
|
||||||
Some(format!(
|
Some(format!(
|
||||||
"Building index ({} / ~55 GB estimated). Electrum RPC will be available when complete.",
|
"Building index ({} / ~130 GB estimated). Electrum RPC will be available when complete.",
|
||||||
size_str
|
size_str
|
||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -298,6 +298,7 @@ pub async fn accept_invite(
|
|||||||
local_did: &str,
|
local_did: &str,
|
||||||
local_onion: &str,
|
local_onion: &str,
|
||||||
local_pubkey: &str,
|
local_pubkey: &str,
|
||||||
|
sign_fn: impl FnOnce(&[u8]) -> String,
|
||||||
) -> Result<FederatedNode> {
|
) -> Result<FederatedNode> {
|
||||||
let (did, onion, pubkey, _token) = parse_invite(code)?;
|
let (did, onion, pubkey, _token) = parse_invite(code)?;
|
||||||
|
|
||||||
@ -333,17 +334,19 @@ pub async fn accept_invite(
|
|||||||
save_invites(data_dir, &invites).await?;
|
save_invites(data_dir, &invites).await?;
|
||||||
|
|
||||||
// Notify remote node (best-effort over Tor)
|
// Notify remote node (best-effort over Tor)
|
||||||
let _ = notify_join(&node.onion, local_did, local_onion, local_pubkey).await;
|
let _ = notify_join(&node.onion, local_did, local_onion, local_pubkey, sign_fn).await;
|
||||||
|
|
||||||
Ok(node)
|
Ok(node)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Best-effort notification to the remote node that we joined their federation.
|
/// Best-effort notification to the remote node that we joined their federation.
|
||||||
|
/// Signs the message with our ed25519 key so the remote peer can verify authenticity.
|
||||||
async fn notify_join(
|
async fn notify_join(
|
||||||
remote_onion: &str,
|
remote_onion: &str,
|
||||||
local_did: &str,
|
local_did: &str,
|
||||||
local_onion: &str,
|
local_onion: &str,
|
||||||
local_pubkey: &str,
|
local_pubkey: &str,
|
||||||
|
sign_fn: impl FnOnce(&[u8]) -> String,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let host = if remote_onion.ends_with(".onion") {
|
let host = if remote_onion.ends_with(".onion") {
|
||||||
remote_onion.to_string()
|
remote_onion.to_string()
|
||||||
@ -351,12 +354,18 @@ async fn notify_join(
|
|||||||
format!("{}.onion", remote_onion)
|
format!("{}.onion", remote_onion)
|
||||||
};
|
};
|
||||||
let url = format!("http://{}/rpc/v1", host);
|
let url = format!("http://{}/rpc/v1", host);
|
||||||
|
|
||||||
|
// Sign the canonical message: "peer-joined:{did}:{onion}:{pubkey}"
|
||||||
|
let sign_data = format!("peer-joined:{}:{}:{}", local_did, local_onion, local_pubkey);
|
||||||
|
let signature = sign_fn(sign_data.as_bytes());
|
||||||
|
|
||||||
let body = serde_json::json!({
|
let body = serde_json::json!({
|
||||||
"method": "federation.peer-joined",
|
"method": "federation.peer-joined",
|
||||||
"params": {
|
"params": {
|
||||||
"did": local_did,
|
"did": local_did,
|
||||||
"onion": local_onion,
|
"onion": local_onion,
|
||||||
"pubkey": local_pubkey,
|
"pubkey": local_pubkey,
|
||||||
|
"signature": signature,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -392,7 +392,7 @@ impl SessionStore {
|
|||||||
now.saturating_sub(ts_bytes) < REMEMBER_TTL
|
now.saturating_sub(ts_bytes) < REMEMBER_TTL
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_or_create_remember_secret() -> Vec<u8> {
|
pub fn load_or_create_remember_secret() -> Vec<u8> {
|
||||||
// Try existing secret file first (backwards compatibility)
|
// Try existing secret file first (backwards compatibility)
|
||||||
if let Ok(secret) = std::fs::read(REMEMBER_SECRET_FILE) {
|
if let Ok(secret) = std::fs::read(REMEMBER_SECRET_FILE) {
|
||||||
if secret.len() == 32 {
|
if secret.len() == 32 {
|
||||||
|
|||||||
@ -26,18 +26,18 @@ PHASE 3: Beta Live (public release)
|
|||||||
|
|
||||||
Everything in this phase must pass before we hand it to real users.
|
Everything in this phase must pass before we hand it to real users.
|
||||||
|
|
||||||
### Overall Status: EARLY (~15%)
|
### Overall Status: IN PROGRESS (~35%)
|
||||||
|
|
||||||
| Workstream | Status | Completion | Gate-blocking? |
|
| Workstream | Status | Completion | Gate-blocking? |
|
||||||
|------------|--------|------------|----------------|
|
|------------|--------|------------|----------------|
|
||||||
| 1A. Critical Bugs | NOT STARTED | 0% | YES |
|
| 1A. Critical Bugs (BUG-1 CSRF) | NOT STARTED | 0% | YES |
|
||||||
| 1B. Boot Screen (FEATURE-4) | IN PROGRESS | ~20% | YES |
|
| 1B. Boot Screen (FEATURE-4) | IN PROGRESS | ~20% | YES |
|
||||||
| 1C. Security Hardening | PARTIAL | ~30% | YES |
|
| 1C. Security Hardening (TASK-8) | IN PROGRESS | ~75% (9/12 fixed) | YES |
|
||||||
| 1D. Rootless Podman (TASK-11) | NOT STARTED | 0% | YES |
|
| 1D. Rootless Podman (TASK-11) | DONE (.228), IN PROGRESS (.198) | ~80% | YES |
|
||||||
| 1E. Beta Telemetry (TASK-12) | NOT STARTED | 0% | YES |
|
| 1E. Beta Telemetry (TASK-12) | NOT STARTED | 0% | YES |
|
||||||
| 1F. App Testing — every feature | NOT STARTED | 0% | YES |
|
| 1F. App Testing — every feature | NOT STARTED | 0% | YES |
|
||||||
| 1G. ISO Build & Fresh Install | NOT STARTED | 0% | YES |
|
| 1G. ISO Build & Fresh Install | NOT STARTED | 0% | YES |
|
||||||
| 1H. UI Polish & Layout | NOT STARTED | 0% | No |
|
| 1H. UI Polish & Layout | DONE (batch) | ~80% | No |
|
||||||
| 1I. WebSocket Reliability | NOT STARTED | 0% | No |
|
| 1I. WebSocket Reliability | NOT STARTED | 0% | No |
|
||||||
| 1J. Quality Baseline Check | NOT STARTED | 0% | No |
|
| 1J. Quality Baseline Check | NOT STARTED | 0% | No |
|
||||||
|
|
||||||
@ -83,45 +83,41 @@ Everything in this phase must pass before we hand it to real users.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 1C. Security Hardening
|
### 1C. Security Hardening (TASK-8)
|
||||||
|
|
||||||
**Status**: PARTIAL — 2 critical, 5 high issues from March audit
|
**Status**: IN PROGRESS — 9 of 12 pentest findings fixed (commits `27f205f`, `c1db74e`)
|
||||||
|
|
||||||
#### Critical (must fix before user testing)
|
#### Fixed (9/12)
|
||||||
|
- [x] C1: /lnd-connect-info requires session auth
|
||||||
|
- [x] C3: DEV_MODE removed from production service
|
||||||
|
- [x] H1: node-message verifies ed25519 signatures
|
||||||
|
- [x] M1: content.add rejects `..` path traversal
|
||||||
|
- [x] M2: NIP-07 postMessage uses specific origin
|
||||||
|
- [x] M3: AIUI nginx checks session_id cookie
|
||||||
|
- [x] L2: Strict v3 onion validation
|
||||||
|
- [x] MED-03: Shell injection in bitcoin.conf generation
|
||||||
|
- [x] MED-07: No body size limit on /rpc/
|
||||||
|
|
||||||
| ID | Issue | Status |
|
#### Remaining (3/12)
|
||||||
|----|-------|--------|
|
- [ ] H2: Federation peer-joined signature verification
|
||||||
| CRIT-01 | Deterministic encryption key (derived from path) | DEFERRED — needs Argon2/TPM redesign |
|
- [ ] H3: Federation address-changed signature verification
|
||||||
| CRIT-02 | Hardcoded Bitcoin RPC password (`archipelago123`) | DEFERRED — needs per-install random gen |
|
- [ ] H4: Bind service ports to 127.0.0.1 (Bitcoin RPC, LND, etc.)
|
||||||
|
|
||||||
#### High (must fix before user testing)
|
|
||||||
|
|
||||||
| ID | Issue | Status |
|
|
||||||
|----|-------|--------|
|
|
||||||
| HIGH-01 | CSP headers not set | DEFERRED |
|
|
||||||
| HIGH-02 | HSTS not enabled | DEFERRED |
|
|
||||||
| HIGH-03 | Rate limit IP spoofing (X-Forwarded-For) | DEFERRED |
|
|
||||||
| HIGH-04 | Bitcoin RPC bound to 0.0.0.0 | DEFERRED |
|
|
||||||
| HIGH-05 | (from audit) | DEFERRED |
|
|
||||||
|
|
||||||
#### Already fixed
|
|
||||||
- MED-03: Shell injection in bitcoin.conf generation
|
|
||||||
- MED-07: No body size limit on /rpc/
|
|
||||||
|
|
||||||
#### Decision needed
|
|
||||||
- CRIT-01 and CRIT-02 are architectural. Are these user-testing-blocking or can they ship with known-issue documentation?
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 1D. Rootless Podman (TASK-11)
|
### 1D. Rootless Podman (TASK-11)
|
||||||
|
|
||||||
**Status**: NOT STARTED
|
**Status**: DONE on .228 (30 containers rootless), IN PROGRESS on .198
|
||||||
**Impact**: Security posture — containers should not require root.
|
**Impact**: Security posture — containers no longer require root.
|
||||||
|
|
||||||
- [ ] Investigate rootless podman feasibility for all current apps
|
- [x] Migrate existing root Podman containers to rootless (archipelago user)
|
||||||
- [ ] Migrate container creation to rootless
|
- [x] Update PodmanClient to run `podman` directly (no sudo) — 9 Rust files
|
||||||
- [ ] Restore any security hardening lost during development
|
- [x] Deploy script auto-fixes ownership + sysctl + linger on every deploy
|
||||||
- [ ] Verify all apps still work after migration
|
- [x] All 30 containers running rootless on .228
|
||||||
|
- [ ] .198: only 2 containers running — needs full container recreation (TASK-39)
|
||||||
|
- [x] Tailscale deploy script: full deploy-tailscale.sh with split-mode SSH, rootful→rootless migration, container creation, all infrastructure
|
||||||
|
- [ ] Test full deploy on .198 (validation before Tailscale)
|
||||||
|
- [ ] Deploy to Tailscale nodes (Arch 1/2/3)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -230,13 +226,23 @@ Systematic test of **every feature** on the dev server, then on fresh install.
|
|||||||
|
|
||||||
### 1H. UI Polish & Layout
|
### 1H. UI Polish & Layout
|
||||||
|
|
||||||
**Status**: NOT STARTED
|
**Status**: MOSTLY DONE — batch of fixes shipped 2026-03-18
|
||||||
**Note**: Layout rearrangements and UX improvements allowed during freeze.
|
**Note**: Layout rearrangements and UX improvements allowed during freeze.
|
||||||
|
|
||||||
|
- [x] Rename fedimintd → "Fedimint Guardian" + icon (TASK-26)
|
||||||
|
- [x] Tab-launch icons for apps opening in new tabs (TASK-27)
|
||||||
|
- [x] Installed apps sorted to end of marketplace (TASK-28)
|
||||||
|
- [x] Mesh mobile: header hidden, overflow fixed (TASK-29)
|
||||||
|
- [x] On-Chain first in receive modals (TASK-30)
|
||||||
|
- [x] Federation node names — show name not DID, hover for key (TASK-35)
|
||||||
|
- [x] Cleaner iframe error screen with remediation (TASK-36)
|
||||||
|
- [x] CPU alert threshold fixed (BUG-33)
|
||||||
|
- [x] ElectrumX shows index size during indexing
|
||||||
|
- [x] Container startup "Checking..." shimmer
|
||||||
|
- [ ] Sticky nav header (TASK-31)
|
||||||
- [ ] Review all views for consistent glass design
|
- [ ] Review all views for consistent glass design
|
||||||
- [ ] Verify all loading/empty/error states work
|
- [ ] Verify all loading/empty/error states work
|
||||||
- [ ] Check responsive layout on tablet/mobile
|
- [ ] Check responsive layout on tablet/mobile
|
||||||
- [ ] Audit all button states (disabled during submit, etc.)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -300,6 +306,8 @@ Starts when we hand ISOs to real users on real hardware we don't control.
|
|||||||
|------|---------|-----------|--------------|
|
|------|---------|-----------|--------------|
|
||||||
| 2026-03-18 | #1 | Created beta freeze plan, progress tracker | — |
|
| 2026-03-18 | #1 | Created beta freeze plan, progress tracker | — |
|
||||||
| 2026-03-18 | #2 | Restructured into 3-phase pipeline, added telemetry workstream | — |
|
| 2026-03-18 | #2 | Restructured into 3-phase pipeline, added telemetry workstream | — |
|
||||||
|
| 2026-03-18 | #3 | Updated tracking to reflect completed work — TASK-11 done, TASK-8 9/12, UI batch done | TASK-11, TASK-26-30, TASK-32, TASK-34-36, BUG-33 |
|
||||||
|
| 2026-03-18 | #4 | Rewrote deploy-tailscale.sh (full deploy with split-mode SSH, rootful migration, containers, infra). Fixed first-boot-containers.sh rootless bugs (subnet, UID mapping, prereqs). Dynamic HTTPS certs. | — |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -12,29 +12,21 @@
|
|||||||
|
|
||||||
| ID | Title | Priority | Status | Dependencies |
|
| ID | Title | Priority | Status | Dependencies |
|
||||||
|----|-------|----------|--------|--------------|
|
|----|-------|----------|--------|--------------|
|
||||||
| **BUG-20** | **ElectrumX always shows "Building..." not height** | **P2** | PLANNED | - |
|
|
||||||
| **TASK-26** | **Rename fedimintd to "Fedimint Guardian" + icon** | **P2** | PLANNED | - |
|
|
||||||
| **TASK-27** | **Add tab-launch icon to apps that open in tabs** | **P2** | PLANNED | - |
|
|
||||||
| **TASK-28** | **Sort installed apps to end of marketplace** | **P2** | PLANNED | - |
|
|
||||||
| **TASK-29** | **Fix mesh mobile: remove title/flash/peers header, fix gutters** | **P2** | PLANNED | - |
|
|
||||||
| **TASK-30** | **On-Chain as first tab in receive Bitcoin modals** | **P2** | PLANNED | - |
|
|
||||||
| **TASK-31** | **Sticky nav header (My Apps/App Store/Services + categories + search)** | **P2** | PLANNED | - |
|
|
||||||
| **TASK-32** | **Integrate boot loader into deploy + build + production** | **P1** | ~~DONE~~ | - |
|
|
||||||
| **BUG-33** | **CPU load alert threshold too low (8 = 2x cores)** | **P2** | PLANNED | - |
|
|
||||||
| **TASK-34** | **Pentest findings remediation plan** | **P1** | PLANNED | - |
|
|
||||||
| **TASK-35** | **Federation node names (show name not DID, hover for key)** | **P2** | PLANNED | - |
|
|
||||||
| **TASK-36** | **Cleaner iframe error screen with remediation** | **P2** | PLANNED | - |
|
|
||||||
| **BUG-37** | **Apps flicker Start/Launch during container scan** | **P2** | PLANNED | - |
|
|
||||||
| **TASK-38** | **Add blockchain sync info to homepage System card** | **P2** | PLANNED | - |
|
|
||||||
| **BUG-1** | **Random logout / CSRF mismatch** | **P0** | PLANNED | - |
|
| **BUG-1** | **Random logout / CSRF mismatch** | **P0** | PLANNED | - |
|
||||||
|
| **TASK-8** | **Security hardening (9/12 fixed, H2/H3/H4 remain)** | **P0** | IN PROGRESS | - |
|
||||||
| **FEATURE-4** | **Onboarding loading screen with progress** | **P1** | IN PROGRESS | - |
|
| **FEATURE-4** | **Onboarding loading screen with progress** | **P1** | IN PROGRESS | - |
|
||||||
| **BUG-3** | **IndeedHub WebSocket spam in console** | **P2** | PLANNED | - |
|
|
||||||
| **TASK-8** | **Security hardening (CRIT-01, CRIT-02, HIGHs)** | **P0** | PLANNED | - |
|
|
||||||
| **TASK-9** | **Full feature testing sweep** | **P1** | PLANNED | - |
|
| **TASK-9** | **Full feature testing sweep** | **P1** | PLANNED | - |
|
||||||
| **TASK-10** | **ISO build verification + multi-hardware test** | **P1** | PLANNED | - |
|
| **TASK-10** | **ISO build verification + multi-hardware test** | **P1** | PLANNED | - |
|
||||||
| **TASK-11** | **Rootless podman + restore security hardening** | **P1** | ~~DONE~~ | - |
|
|
||||||
| **TASK-12** | **Beta telemetry — node reporting + monitoring panel** | **P1** | PLANNED | - |
|
| **TASK-12** | **Beta telemetry — node reporting + monitoring panel** | **P1** | PLANNED | - |
|
||||||
|
| **BUG-20** | **ElectrumX always shows "Building..." not height** | **P2** | PLANNED | - |
|
||||||
|
| **TASK-31** | **Sticky nav header (My Apps/App Store/Services + categories + search)** | **P2** | PLANNED | - |
|
||||||
|
| **BUG-37** | **Apps flicker Start/Launch during container scan** | **P2** | PLANNED | - |
|
||||||
|
| **TASK-38** | **Add blockchain sync info to homepage System card** | **P2** | PLANNED | - |
|
||||||
|
| **BUG-3** | **IndeedHub WebSocket spam in console** | **P2** | PLANNED | - |
|
||||||
| **TASK-17** | **Alpha version tags + rollback strategy** | **P2** | PLANNED | - |
|
| **TASK-17** | **Alpha version tags + rollback strategy** | **P2** | PLANNED | - |
|
||||||
|
| **TASK-39** | **Finish .198 rootless container migration** | **P1** | PLANNED | TASK-11 |
|
||||||
|
| **BUG-40** | **Uninstall dialog not full-screen modal** | **P2** | PLANNED | - |
|
||||||
|
| **BUG-41** | **Uninstall loader ends but app card persists** | **P2** | PLANNED | - |
|
||||||
|
|
||||||
### Phase 2: User Testing (controlled, real hardware)
|
### Phase 2: User Testing (controlled, real hardware)
|
||||||
|
|
||||||
@ -147,22 +139,27 @@ Users hit the onboarding screen before the backend is ready, resulting in "Serve
|
|||||||
- [ ] Handle edge cases: very slow starts, partial service failures, timeout fallback
|
- [ ] Handle edge cases: very slow starts, partial service failures, timeout fallback
|
||||||
- [ ] Test on fresh ISO install (first-boot scenario)
|
- [ ] Test on fresh ISO install (first-boot scenario)
|
||||||
|
|
||||||
### TASK-8: Security hardening — critical and high findings (PLANNED)
|
### TASK-8: Security hardening — 9/12 findings fixed (IN PROGRESS)
|
||||||
**Priority**: P0 — Critical
|
**Priority**: P0 — Critical
|
||||||
**Status**: PLANNED (2026-03-18)
|
**Status**: IN PROGRESS (2026-03-18) — 9 of 12 pentest findings fixed
|
||||||
|
|
||||||
Fix the critical and high security findings from the March 2026 audit before beta ships.
|
|
||||||
|
|
||||||
**Reference**: `docs/security-audit-2026-03-11.md`
|
**Reference**: `docs/security-audit-2026-03-11.md`
|
||||||
|
|
||||||
**Tasks**:
|
**Fixed** (commits `27f205f`, `c1db74e`):
|
||||||
- [ ] CRIT-02: Replace hardcoded Bitcoin RPC password with per-install random generation
|
- [x] C1: /lnd-connect-info requires session auth
|
||||||
- [ ] CRIT-01: Redesign secrets encryption key derivation (Argon2 from user password or hardware-backed)
|
- [x] C3: DEV_MODE removed from production service
|
||||||
- [ ] HIGH-01: Add Content-Security-Policy headers to nginx
|
- [x] H1: node-message verifies ed25519 signatures
|
||||||
- [ ] HIGH-02: Enable HSTS in nginx
|
- [x] M1: content.add rejects `..` path traversal
|
||||||
- [ ] HIGH-03: Fix rate limit IP spoofing (trust only known proxies for X-Forwarded-For)
|
- [x] M2: NIP-07 postMessage uses specific origin
|
||||||
- [ ] HIGH-04: Bind Bitcoin RPC to localhost only (not 0.0.0.0)
|
- [x] M3: AIUI nginx checks session_id cookie
|
||||||
- [ ] HIGH-05: Remaining high finding from audit
|
- [x] L2: Strict v3 onion validation
|
||||||
|
- [x] MED-03: Shell injection in bitcoin.conf generation
|
||||||
|
- [x] MED-07: No body size limit on /rpc/
|
||||||
|
|
||||||
|
**Remaining**:
|
||||||
|
- [ ] H2: Federation peer-joined signature verification
|
||||||
|
- [ ] H3: Federation address-changed signature verification
|
||||||
|
- [ ] H4: Bind service ports to 127.0.0.1 (Bitcoin RPC, LND, etc.)
|
||||||
|
|
||||||
### TASK-9: Full app testing matrix on fresh install (PLANNED)
|
### TASK-9: Full app testing matrix on fresh install (PLANNED)
|
||||||
**Priority**: P1 — High
|
**Priority**: P1 — High
|
||||||
@ -178,24 +175,6 @@ Build a fresh ISO, install on at least 2 different hardware configurations, veri
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### TASK-11: Rootless podman + restore security hardening (PLANNED)
|
|
||||||
**Priority**: P1 — High
|
|
||||||
**Status**: PLANNED (2026-03-18)
|
|
||||||
|
|
||||||
Migrate from `sudo podman` (root containers) to rootless podman so the systemd service can run with `NoNewPrivileges=yes` and `SystemCallFilter` restrictions. Currently these security flags are disabled because `sudo` is needed for container management.
|
|
||||||
|
|
||||||
**Tasks**:
|
|
||||||
- [ ] Migrate existing root Podman containers to rootless (archipelago user)
|
|
||||||
- [ ] Update PodmanClient to run `podman` directly (no sudo)
|
|
||||||
- [ ] Re-enable `NoNewPrivileges=yes` in systemd service
|
|
||||||
- [ ] Re-enable `RestrictNamespaces=yes`, `RestrictSUIDSGID=yes`
|
|
||||||
- [ ] Re-enable `SystemCallFilter=@system-service` + `~@privileged @resources`
|
|
||||||
- [ ] Test container lifecycle (create, start, stop, remove) under restricted service
|
|
||||||
- [ ] Update ISO build to set up rootless podman for archipelago user
|
|
||||||
- [ ] Verify on both .228 and .198
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### TASK-17: Alpha version tags + rollback strategy (PLANNED)
|
### TASK-17: Alpha version tags + rollback strategy (PLANNED)
|
||||||
**Priority**: P2 — Medium
|
**Priority**: P2 — Medium
|
||||||
**Status**: PLANNED (2026-03-18)
|
**Status**: PLANNED (2026-03-18)
|
||||||
@ -212,6 +191,29 @@ Tag every significant alpha version with git tags for easy rollback. Each tag sh
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### BUG-40: Uninstall dialog not full-screen modal (PLANNED)
|
||||||
|
**Priority**: P2 — Medium
|
||||||
|
**Status**: PLANNED (2026-03-18)
|
||||||
|
|
||||||
|
The uninstall confirmation dialog renders as a small centered card instead of a full-screen modal overlay like all other modals. The sidebar and background content are fully visible behind it — should use the same full-screen backdrop pattern.
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
- [ ] Find the uninstall confirmation component and add full-screen backdrop
|
||||||
|
- [ ] Match the modal pattern used by other dialogs (e.g., send/receive modals)
|
||||||
|
|
||||||
|
### BUG-41: Uninstall loader ends but app card persists (PLANNED)
|
||||||
|
**Priority**: P2 — Medium
|
||||||
|
**Status**: PLANNED (2026-03-18)
|
||||||
|
|
||||||
|
After clicking Uninstall, the loading spinner finishes but the app card remains visible. Need an "Uninstalling..." state on the card that persists until the card is actually removed from the list.
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
- [ ] Add `uninstalling` state to app cards
|
||||||
|
- [ ] Show "Uninstalling..." overlay on the card after confirm
|
||||||
|
- [ ] Keep state until container is fully removed and card disappears from the list
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Post-Beta (FROZEN)
|
## Post-Beta (FROZEN)
|
||||||
|
|
||||||
*These tasks are deferred until after beta ships. Do not start.*
|
*These tasks are deferred until after beta ships. Do not start.*
|
||||||
@ -223,6 +225,16 @@ Tag every significant alpha version with git tags for easy rollback. Each tag sh
|
|||||||
|
|
||||||
## Completed
|
## Completed
|
||||||
|
|
||||||
<!-- Done tasks are moved here -->
|
| ID | Title | Completed |
|
||||||
|
|----|-------|-----------|
|
||||||
<!-- Done tasks are moved here -->
|
| **TASK-11** | Rootless podman migration (.228 — 30 containers) | 2026-03-18 |
|
||||||
|
| **TASK-32** | Integrate boot loader into deploy + build + production | 2026-03-17 |
|
||||||
|
| **TASK-34** | Pentest findings remediation plan | 2026-03-18 |
|
||||||
|
| **TASK-26** | Rename fedimintd to "Fedimint Guardian" + icon | 2026-03-18 |
|
||||||
|
| **TASK-27** | Add tab-launch icon to apps that open in tabs | 2026-03-18 |
|
||||||
|
| **TASK-28** | Sort installed apps to end of marketplace | 2026-03-18 |
|
||||||
|
| **TASK-29** | Fix mesh mobile: remove title/flash/peers header, fix gutters | 2026-03-18 |
|
||||||
|
| **TASK-30** | On-Chain as first tab in receive Bitcoin modals | 2026-03-18 |
|
||||||
|
| **TASK-35** | Federation node names (show name not DID, hover for key) | 2026-03-18 |
|
||||||
|
| **TASK-36** | Cleaner iframe error screen with remediation | 2026-03-18 |
|
||||||
|
| **BUG-33** | CPU load alert threshold too low (8 = 2x cores) | 2026-03-18 |
|
||||||
|
|||||||
@ -68,6 +68,12 @@ class RPCClient {
|
|||||||
}
|
}
|
||||||
throw new Error('Session expired')
|
throw new Error('Session expired')
|
||||||
}
|
}
|
||||||
|
// CSRF 403: retry once after short delay (cookie may have been
|
||||||
|
// updated by a concurrent Set-Cookie response not yet visible to JS)
|
||||||
|
if (response.status === 403 && attempt < maxRetries - 1) {
|
||||||
|
await new Promise((r) => setTimeout(r, 300))
|
||||||
|
continue
|
||||||
|
}
|
||||||
const err = new Error(`HTTP ${response.status}: ${response.statusText}`)
|
const err = new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||||
const isRetryable = response.status === 502 || response.status === 503
|
const isRetryable = response.status === 502 || response.status === 503
|
||||||
if (isRetryable && attempt < maxRetries - 1) {
|
if (isRetryable && attempt < maxRetries - 1) {
|
||||||
|
|||||||
@ -410,50 +410,52 @@
|
|||||||
<p class="text-white/70">{{ t('appDetails.notFoundMessage') }}</p>
|
<p class="text-white/70">{{ t('appDetails.notFoundMessage') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Uninstall Confirmation Modal -->
|
<!-- Uninstall Confirmation Modal — Teleport to body to escape sidebar stacking context -->
|
||||||
<Transition name="modal">
|
<Teleport to="body">
|
||||||
<div
|
<Transition name="modal">
|
||||||
v-if="uninstallModal.show"
|
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
|
||||||
@click="closeUninstallModal()"
|
|
||||||
>
|
|
||||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
|
||||||
<div
|
<div
|
||||||
ref="uninstallModalRef"
|
v-if="uninstallModal.show"
|
||||||
@click.stop
|
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
|
||||||
class="glass-card p-6 md:p-8 max-w-md w-full relative z-10"
|
@click="closeUninstallModal()"
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-4 mb-6">
|
<div class="absolute inset-0 bg-black/60 backdrop-blur-md"></div>
|
||||||
<div class="p-3 bg-red-500/20 rounded-lg flex-shrink-0">
|
<div
|
||||||
<svg class="w-6 h-6 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
ref="uninstallModalRef"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
@click.stop
|
||||||
</svg>
|
class="glass-card p-6 md:p-8 max-w-md w-full relative z-10"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-4 mb-6">
|
||||||
|
<div class="p-3 bg-red-500/20 rounded-lg flex-shrink-0">
|
||||||
|
<svg class="w-6 h-6 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h3 class="text-xl font-semibold text-white mb-2">{{ t('appDetails.uninstallTitle') }}</h3>
|
||||||
|
<p class="text-white/70 text-sm">
|
||||||
|
{{ t('appDetails.uninstallConfirm', { name: uninstallModal.appTitle }) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<h3 class="text-xl font-semibold text-white mb-2">{{ t('appDetails.uninstallTitle') }}</h3>
|
|
||||||
<p class="text-white/70 text-sm">
|
|
||||||
{{ t('appDetails.uninstallConfirm', { name: uninstallModal.appTitle }) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col-reverse md:flex-row gap-3 md:justify-end">
|
<div class="flex flex-col-reverse md:flex-row gap-3 md:justify-end">
|
||||||
<button
|
<button
|
||||||
@click="closeUninstallModal()"
|
@click="closeUninstallModal()"
|
||||||
class="w-full md:w-auto px-6 py-3 glass-button rounded-lg text-sm font-medium"
|
class="w-full md:w-auto px-6 py-3 glass-button rounded-lg text-sm font-medium"
|
||||||
>
|
>
|
||||||
{{ t('common.cancel') }}
|
{{ t('common.cancel') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="confirmUninstall"
|
@click="confirmUninstall"
|
||||||
class="w-full md:w-auto px-6 py-3 bg-red-600/80 hover:bg-red-600 rounded-lg text-white text-sm font-medium transition-colors"
|
class="w-full md:w-auto px-6 py-3 bg-red-600/80 hover:bg-red-600 rounded-lg text-white text-sm font-medium transition-colors"
|
||||||
>
|
>
|
||||||
{{ t('common.uninstall') }}
|
{{ t('common.uninstall') }}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Transition>
|
||||||
</Transition>
|
</Teleport>
|
||||||
|
|
||||||
<!-- Action error toast -->
|
<!-- Action error toast -->
|
||||||
<Transition name="fade">
|
<Transition name="fade">
|
||||||
|
|||||||
@ -89,9 +89,23 @@
|
|||||||
@click="goToApp(id as string)"
|
@click="goToApp(id as string)"
|
||||||
@keydown.enter="goToApp(id as string)"
|
@keydown.enter="goToApp(id as string)"
|
||||||
>
|
>
|
||||||
|
<!-- Uninstalling overlay -->
|
||||||
|
<div
|
||||||
|
v-if="uninstallingApps.has(id as string)"
|
||||||
|
class="absolute inset-0 z-20 flex items-center justify-center bg-black/70 backdrop-blur-sm rounded-xl"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3 text-white/90">
|
||||||
|
<svg class="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm font-medium">{{ t('common.uninstalling') }}...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Uninstall Icon (not for web-only apps) -->
|
<!-- Uninstall Icon (not for web-only apps) -->
|
||||||
<button
|
<button
|
||||||
v-if="!isWebOnlyApp(id as string)"
|
v-if="!isWebOnlyApp(id as string) && !uninstallingApps.has(id as string)"
|
||||||
@click.stop="showUninstallModal(id as string, pkg)"
|
@click.stop="showUninstallModal(id as string, pkg)"
|
||||||
class="absolute top-4 right-4 p-2 rounded-lg text-white/60 hover:text-red-400 hover:bg-red-500/20 transition-colors z-10"
|
class="absolute top-4 right-4 p-2 rounded-lg text-white/60 hover:text-red-400 hover:bg-red-500/20 transition-colors z-10"
|
||||||
:aria-label="`${t('common.uninstall')} ${pkg.manifest?.title || id}`"
|
:aria-label="`${t('common.uninstall')} ${pkg.manifest?.title || id}`"
|
||||||
@ -140,8 +154,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quick Actions -->
|
<!-- Quick Actions — hide during uninstall, freeze during loading actions to prevent flicker -->
|
||||||
<div class="mt-4 flex gap-2">
|
<div v-if="!uninstallingApps.has(id as string)" class="mt-4 flex gap-2">
|
||||||
<button
|
<button
|
||||||
v-if="canLaunch(pkg)"
|
v-if="canLaunch(pkg)"
|
||||||
data-controller-launch-btn
|
data-controller-launch-btn
|
||||||
@ -152,105 +166,105 @@
|
|||||||
<svg v-if="opensInTab(id as string)" class="w-3.5 h-3.5 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
|
<svg v-if="opensInTab(id as string)" class="w-3.5 h-3.5 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="!isWebOnlyApp(id as string) && (pkg.state === 'stopped' || pkg.state === 'exited')"
|
v-if="!isWebOnlyApp(id as string) && !loadingActions[id as string] && (pkg.state === 'stopped' || pkg.state === 'exited')"
|
||||||
@click.stop="startApp(id as string)"
|
@click.stop="startApp(id as string)"
|
||||||
:disabled="loadingActions[id as string]"
|
class="flex-1 px-4 py-2 bg-green-500/20 border border-green-500/40 rounded-lg text-green-200 text-sm font-medium hover:bg-green-500/30 transition-colors flex items-center justify-center gap-2"
|
||||||
class="flex-1 px-4 py-2 bg-green-500/20 border border-green-500/40 rounded-lg text-green-200 text-sm font-medium hover:bg-green-500/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|
||||||
>
|
>
|
||||||
<svg
|
<span>{{ t('common.start') }}</span>
|
||||||
v-if="loadingActions[id as string]"
|
|
||||||
class="animate-spin h-4 w-4"
|
|
||||||
aria-hidden="true"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
<span>{{ loadingActions[id as string] ? t('common.starting') : t('common.start') }}</span>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="!isWebOnlyApp(id as string) && (pkg.state === 'running' || pkg.state === 'starting')"
|
v-if="!isWebOnlyApp(id as string) && loadingActions[id as string] && (pkg.state === 'stopped' || pkg.state === 'exited' || pkg.state === 'starting')"
|
||||||
@click.stop="stopApp(id as string)"
|
disabled
|
||||||
:disabled="loadingActions[id as string]"
|
class="flex-1 px-4 py-2 bg-green-500/20 border border-green-500/40 rounded-lg text-green-200 text-sm font-medium opacity-50 cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
class="flex-1 px-4 py-2 bg-yellow-500/20 border border-yellow-500/40 rounded-lg text-yellow-200 text-sm font-medium hover:bg-yellow-500/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg class="animate-spin h-4 w-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
v-if="loadingActions[id as string]"
|
|
||||||
class="animate-spin h-4 w-4"
|
|
||||||
aria-hidden="true"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<span>{{ loadingActions[id as string] ? t('common.stopping') : t('common.stop') }}</span>
|
<span>{{ t('common.starting') }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="!isWebOnlyApp(id as string) && !loadingActions[id as string] && (pkg.state === 'running' || pkg.state === 'starting')"
|
||||||
|
@click.stop="stopApp(id as string)"
|
||||||
|
class="flex-1 px-4 py-2 bg-yellow-500/20 border border-yellow-500/40 rounded-lg text-yellow-200 text-sm font-medium hover:bg-yellow-500/30 transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<span>{{ t('common.stop') }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="!isWebOnlyApp(id as string) && loadingActions[id as string] && (pkg.state === 'running' || pkg.state === 'starting' || pkg.state === 'stopping')"
|
||||||
|
disabled
|
||||||
|
class="flex-1 px-4 py-2 bg-yellow-500/20 border border-yellow-500/40 rounded-lg text-yellow-200 text-sm font-medium opacity-50 cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<svg class="animate-spin h-4 w-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<span>{{ t('common.stopping') }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Uninstall Confirmation Modal -->
|
<!-- Uninstall Confirmation Modal — Teleport to body to escape sidebar stacking context -->
|
||||||
<Transition name="modal">
|
<Teleport to="body">
|
||||||
<div
|
<Transition name="modal">
|
||||||
v-if="uninstallModal.show"
|
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
|
||||||
@click="closeUninstallModal()"
|
|
||||||
>
|
|
||||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-md"></div>
|
|
||||||
<div
|
<div
|
||||||
ref="uninstallModalRef"
|
v-if="uninstallModal.show"
|
||||||
@click.stop
|
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
|
||||||
role="dialog"
|
@click="closeUninstallModal()"
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="uninstall-dialog-title"
|
|
||||||
class="glass-card p-6 max-w-2xl w-full relative z-10"
|
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-4 mb-4">
|
<div class="absolute inset-0 bg-black/60 backdrop-blur-md"></div>
|
||||||
<div class="p-3 bg-red-500/20 rounded-lg">
|
<div
|
||||||
<svg class="w-6 h-6 text-red-400" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
ref="uninstallModalRef"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
@click.stop
|
||||||
</svg>
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="uninstall-dialog-title"
|
||||||
|
class="glass-card p-6 max-w-2xl w-full relative z-10"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-4 mb-4">
|
||||||
|
<div class="p-3 bg-red-500/20 rounded-lg">
|
||||||
|
<svg class="w-6 h-6 text-red-400" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 id="uninstall-dialog-title" class="text-xl font-semibold text-white mb-2">{{ t('apps.uninstallTitle') }}</h3>
|
||||||
|
<p class="text-white/70">
|
||||||
|
{{ t('apps.uninstallConfirm', { name: uninstallModal.appTitle }) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
|
||||||
<h3 id="uninstall-dialog-title" class="text-xl font-semibold text-white mb-2">{{ t('apps.uninstallTitle') }}</h3>
|
|
||||||
<p class="text-white/70">
|
|
||||||
{{ t('apps.uninstallConfirm', { name: uninstallModal.appTitle }) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-3 justify-end">
|
<div class="flex gap-3 justify-end">
|
||||||
<button
|
<button
|
||||||
@click="closeUninstallModal()"
|
@click="closeUninstallModal()"
|
||||||
class="px-4 py-2 glass-button rounded-lg text-sm font-medium"
|
class="px-4 py-2 glass-button rounded-lg text-sm font-medium"
|
||||||
>
|
|
||||||
{{ t('common.cancel') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="confirmUninstall"
|
|
||||||
:disabled="uninstalling"
|
|
||||||
class="px-4 py-2 bg-red-600/80 hover:bg-red-600 rounded-lg text-white text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
v-if="uninstalling"
|
|
||||||
class="animate-spin h-4 w-4"
|
|
||||||
aria-hidden="true"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
>
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
{{ t('common.cancel') }}
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
</button>
|
||||||
</svg>
|
<button
|
||||||
<span>{{ uninstalling ? t('common.uninstalling') : t('common.uninstall') }}</span>
|
@click="confirmUninstall"
|
||||||
</button>
|
:disabled="uninstalling"
|
||||||
|
class="px-4 py-2 bg-red-600/80 hover:bg-red-600 rounded-lg text-white text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
v-if="uninstalling"
|
||||||
|
class="animate-spin h-4 w-4"
|
||||||
|
aria-hidden="true"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<span>{{ uninstalling ? t('common.uninstalling') : t('common.uninstall') }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Transition>
|
||||||
</Transition>
|
</Teleport>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -576,19 +590,25 @@ function showUninstallModal(id: string, pkg: PackageDataEntry) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const uninstalling = ref(false)
|
const uninstalling = ref(false)
|
||||||
|
const uninstallingApps = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
async function confirmUninstall() {
|
async function confirmUninstall() {
|
||||||
const { appId } = uninstallModal.value
|
const { appId } = uninstallModal.value
|
||||||
uninstalling.value = true
|
uninstalling.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await store.uninstallPackage(appId)
|
|
||||||
uninstallModal.value.show = false
|
uninstallModal.value.show = false
|
||||||
|
uninstallingApps.value.add(appId)
|
||||||
|
await store.uninstallPackage(appId)
|
||||||
|
// Optimistically remove from store so card disappears immediately
|
||||||
|
if (store.packages && store.packages[appId]) {
|
||||||
|
delete store.packages[appId]
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (import.meta.env.DEV) console.error('Failed to uninstall app:', err)
|
if (import.meta.env.DEV) console.error('Failed to uninstall app:', err)
|
||||||
showActionError(`Failed to uninstall app: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
showActionError(`Failed to uninstall app: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||||
uninstallModal.value.show = false
|
|
||||||
} finally {
|
} finally {
|
||||||
|
uninstallingApps.value.delete(appId)
|
||||||
uninstalling.value = false
|
uninstalling.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -249,17 +249,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<!-- Incoming Transactions Badge -->
|
<!-- Transactions button — switches to incoming state when pending txs exist -->
|
||||||
<button
|
<button
|
||||||
v-if="incomingTxCount > 0"
|
@click="incomingTxCount > 0 ? showIncomingTxPanel = !showIncomingTxPanel : showTransactionsModal = true"
|
||||||
@click="showIncomingTxPanel = !showIncomingTxPanel"
|
:class="incomingTxCount > 0 ? 'incoming-tx-badge' : 'text-white/50 hover:text-white/80 text-xs px-2 py-1 rounded-lg bg-white/5 hover:bg-white/10 transition-colors'"
|
||||||
class="incoming-tx-badge shrink-0"
|
class="shrink-0"
|
||||||
>
|
>
|
||||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<template v-if="incomingTxCount > 0">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</svg>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||||
<span>Incoming {{ incomingTxCount }}</span>
|
</svg>
|
||||||
<span class="incoming-tx-ping"></span>
|
<span>Incoming {{ incomingTxCount }}</span>
|
||||||
|
<span class="incoming-tx-ping"></span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<svg class="w-3.5 h-3.5 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
|
||||||
|
</svg>
|
||||||
|
<span>Transactions</span>
|
||||||
|
</template>
|
||||||
</button>
|
</button>
|
||||||
<RouterLink to="/dashboard/web5" :aria-label="t('home.goToWeb5')" class="text-white/60 hover:text-white transition-colors">
|
<RouterLink to="/dashboard/web5" :aria-label="t('home.goToWeb5')" class="text-white/60 hover:text-white transition-colors">
|
||||||
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@ -345,19 +353,19 @@
|
|||||||
<span class="text-purple-400 text-sm font-medium">{{ walletEcash.toLocaleString() }} sats</span>
|
<span class="text-purple-400 text-sm font-medium">{{ walletEcash.toLocaleString() }} sats</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="home-card-buttons grid grid-cols-4 gap-2 mt-auto pt-4 shrink-0">
|
<div class="home-card-buttons grid gap-2 mt-auto pt-4 shrink-0" :class="isDev ? 'grid-cols-4' : 'grid-cols-3'">
|
||||||
<button @click="showSendModal = true" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
<button @click="showSendModal = true" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
||||||
{{ t('common.send') }}
|
{{ t('common.send') }}
|
||||||
</button>
|
</button>
|
||||||
<button @click="showReceiveModal = true" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
<button @click="showReceiveModal = true" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
||||||
{{ t('web5.receiveBitcoin') }}
|
{{ t('web5.receiveBitcoin') }}
|
||||||
</button>
|
</button>
|
||||||
<button @click="devFaucet" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors text-green-400">
|
<button @click="showTransactionsModal = true" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
||||||
|
Transactions
|
||||||
|
</button>
|
||||||
|
<button v-if="isDev" @click="devFaucet" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors text-green-400">
|
||||||
Faucet
|
Faucet
|
||||||
</button>
|
</button>
|
||||||
<RouterLink to="/dashboard/web5" class="home-card-btn px-3 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
|
|
||||||
Web5
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -397,7 +405,7 @@
|
|||||||
<div class="p-4 bg-white/5 rounded-lg">
|
<div class="p-4 bg-white/5 rounded-lg">
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<p class="text-xs text-white/60">{{ t('home.cpu') }}</p>
|
<p class="text-xs text-white/60">{{ t('home.cpu') }}</p>
|
||||||
<p class="text-sm font-medium" :class="gaugeTextColor(systemStats.cpuPercent)">{{ systemStats.cpuPercent.toFixed(0) }}%</p>
|
<p class="text-sm font-medium" :class="gaugeTextColor(systemStats.cpuPercent)">{{ (systemStats.cpuPercent ?? 0).toFixed(0) }}%</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden">
|
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden">
|
||||||
<div class="h-full rounded-full transition-all duration-500" :class="gaugeBarColor(systemStats.cpuPercent)" :style="{ width: systemStats.cpuPercent + '%' }"></div>
|
<div class="h-full rounded-full transition-all duration-500" :class="gaugeBarColor(systemStats.cpuPercent)" :style="{ width: systemStats.cpuPercent + '%' }"></div>
|
||||||
@ -477,9 +485,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Send/Receive Bitcoin Modals -->
|
<!-- Wallet Modals -->
|
||||||
<SendBitcoinModal :show="showSendModal" @close="showSendModal = false" @sent="loadWeb5Status()" />
|
<SendBitcoinModal :show="showSendModal" @close="showSendModal = false" @sent="loadWeb5Status()" />
|
||||||
<ReceiveBitcoinModal :show="showReceiveModal" @close="showReceiveModal = false" @received="loadWeb5Status()" />
|
<ReceiveBitcoinModal :show="showReceiveModal" @close="showReceiveModal = false" @received="loadWeb5Status()" />
|
||||||
|
<TransactionsModal :show="showTransactionsModal" :transactions="walletTransactions" @close="showTransactionsModal = false" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@ -488,6 +497,7 @@ import { RouterLink, useRouter } from 'vue-router'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import SendBitcoinModal from '@/components/SendBitcoinModal.vue'
|
import SendBitcoinModal from '@/components/SendBitcoinModal.vue'
|
||||||
import ReceiveBitcoinModal from '@/components/ReceiveBitcoinModal.vue'
|
import ReceiveBitcoinModal from '@/components/ReceiveBitcoinModal.vue'
|
||||||
|
import TransactionsModal from '@/components/TransactionsModal.vue'
|
||||||
import { useAppStore } from '../stores/app'
|
import { useAppStore } from '../stores/app'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@ -503,6 +513,7 @@ import { rpcClient } from '@/api/rpc-client'
|
|||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const uiMode = useUIModeStore()
|
const uiMode = useUIModeStore()
|
||||||
|
const isDev = import.meta.env.DEV
|
||||||
const homeTab = ref<'dashboard' | 'setup'>('dashboard')
|
const homeTab = ref<'dashboard' | 'setup'>('dashboard')
|
||||||
const topGoals = GOALS.slice(0, 3)
|
const topGoals = GOALS.slice(0, 3)
|
||||||
|
|
||||||
@ -718,18 +729,19 @@ onMounted(async () => {
|
|||||||
loadWeb5Status()
|
loadWeb5Status()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Send/Receive modals
|
// Wallet modals
|
||||||
const showSendModal = ref(false)
|
const showSendModal = ref(false)
|
||||||
const showReceiveModal = ref(false)
|
const showReceiveModal = ref(false)
|
||||||
|
const showTransactionsModal = ref(false)
|
||||||
|
|
||||||
// Dev faucet — adds mock funds to all wallet types
|
// Dev faucet — adds mock funds to all wallet types (dev mode only)
|
||||||
async function devFaucet() {
|
async function devFaucet() {
|
||||||
try {
|
try {
|
||||||
const res = await rpcClient.call<{ message: string }>({ method: 'dev.faucet', params: { amount_sats: 1_000_000 } })
|
const res = await rpcClient.call<{ message: string }>({ method: 'dev.faucet', params: { amount_sats: 1_000_000 } })
|
||||||
console.log('[Faucet]', res.message)
|
if (import.meta.env.DEV) console.log('[Faucet]', res.message)
|
||||||
await loadWeb5Status()
|
await loadWeb5Status()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Faucet] Error:', err)
|
if (import.meta.env.DEV) console.error('[Faucet] Error:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user