wip: preserve two-node IndeeHub UAT fixes
This commit is contained in:
@@ -443,15 +443,23 @@ private fun injectTopInset(view: WebView) {
|
||||
return '';
|
||||
}
|
||||
function apply() {
|
||||
// IndeeHub's browse hero is an image, not a body background.
|
||||
// Padding the body puts a solid strip above the image; let the
|
||||
// hero start at the display edge while push() keeps its fixed
|
||||
// header below the status icons. Other routes keep the normal
|
||||
// content inset.
|
||||
var imageHero = location.port === '7778' &&
|
||||
(location.pathname === '/' || location.pathname === '/browse') &&
|
||||
!!document.querySelector('section img');
|
||||
var bodyBg = document.body
|
||||
? getComputedStyle(document.body).backgroundColor : '';
|
||||
var eff = effectiveTopBg();
|
||||
// Repaint body only when its declared background is not what
|
||||
// the page actually renders at the top (or paints nothing).
|
||||
var fix = eff && eff !== bodyBg
|
||||
var fix = !imageHero && eff && eff !== bodyBg
|
||||
? 'background-color:' + eff + ' !important;' : '';
|
||||
styleEl().textContent =
|
||||
'body{padding-top:' + SAT + 'px !important;' +
|
||||
'body{padding-top:' + (imageHero ? 0 : SAT) + 'px !important;' +
|
||||
'box-sizing:border-box !important;' + fix + '}';
|
||||
}
|
||||
function push(el) {
|
||||
|
||||
@@ -338,6 +338,20 @@ impl ApiHandler {
|
||||
origin == format!("http://{host}") || origin == format!("https://{host}")
|
||||
}
|
||||
|
||||
// Browsers do not send Origin on a same-origin GET. Fetch Metadata is a
|
||||
// browser-controlled header and distinguishes the dashboard from an app on
|
||||
// another port, which is only same-site. Mutating requests and WebSockets
|
||||
// still require the exact Origin above.
|
||||
fn has_terminal_read_origin(headers: &hyper::HeaderMap) -> bool {
|
||||
if headers.contains_key(hyper::header::ORIGIN) {
|
||||
return Self::has_terminal_origin(headers);
|
||||
}
|
||||
headers
|
||||
.get("sec-fetch-site")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
== Some("same-origin")
|
||||
}
|
||||
|
||||
/// Permissive origin check for the share-to-mesh iframe intent: any scheme
|
||||
/// http(s):// followed by the configured host_ip, optionally `:port`. Apps
|
||||
/// proxied under other ports (APP_PORTS) call this from within the same
|
||||
@@ -533,7 +547,25 @@ impl ApiHandler {
|
||||
|
||||
match (method, path.as_str()) {
|
||||
// RPC — auth is handled inside rpc handler per-method
|
||||
(Method::POST, "/rpc/v1") => self.rpc_handler.clone().handle(req_with_bytes).await,
|
||||
(Method::POST, "/rpc/v1") => {
|
||||
let origin = self.app_cors_origin(&headers);
|
||||
let mut response = self.rpc_handler.clone().handle(req_with_bytes).await?;
|
||||
if !origin.is_empty() {
|
||||
response.headers_mut().insert(
|
||||
"access-control-allow-origin",
|
||||
hyper::header::HeaderValue::from_str(&origin)?,
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
"access-control-allow-credentials",
|
||||
hyper::header::HeaderValue::from_static("true"),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
hyper::header::VARY,
|
||||
hyper::header::HeaderValue::from_static("Origin"),
|
||||
);
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
// AIUI model proxy — session-gated forwarder to Claude/Ollama,
|
||||
// replacing the unauthenticated claude-api-proxy.py sidecar and
|
||||
@@ -586,7 +618,7 @@ impl ApiHandler {
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
if !Self::has_terminal_origin(&headers) {
|
||||
if !Self::has_terminal_read_origin(&headers) {
|
||||
return Ok(build_response(
|
||||
StatusCode::FORBIDDEN,
|
||||
"application/json",
|
||||
@@ -939,6 +971,18 @@ mod terminal_origin_tests {
|
||||
ApiHandler::has_terminal_origin(&headers)
|
||||
}
|
||||
|
||||
fn read_allowed(host: &str, origin: Option<&str>, fetch_site: Option<&str>) -> bool {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(HOST, HeaderValue::from_str(host).unwrap());
|
||||
if let Some(origin) = origin {
|
||||
headers.insert(ORIGIN, HeaderValue::from_str(origin).unwrap());
|
||||
}
|
||||
if let Some(fetch_site) = fetch_site {
|
||||
headers.insert("sec-fetch-site", HeaderValue::from_str(fetch_site).unwrap());
|
||||
}
|
||||
ApiHandler::has_terminal_read_origin(&headers)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_accepts_only_the_exact_dashboard_authority() {
|
||||
assert!(allowed("node.local", Some("https://node.local")));
|
||||
@@ -949,4 +993,22 @@ mod terminal_origin_tests {
|
||||
assert!(!allowed("node.local", Some("null")));
|
||||
assert!(!allowed("node.local", None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_read_accepts_same_origin_fetch_but_not_same_site_apps() {
|
||||
assert!(read_allowed("node.local", None, Some("same-origin")));
|
||||
assert!(!read_allowed("node.local", None, Some("same-site")));
|
||||
assert!(!read_allowed("node.local", None, Some("cross-site")));
|
||||
assert!(!read_allowed("node.local", None, None));
|
||||
assert!(!read_allowed(
|
||||
"node.local",
|
||||
Some("https://evil.example"),
|
||||
Some("same-site")
|
||||
));
|
||||
assert!(!read_allowed(
|
||||
"node.local",
|
||||
Some("https://evil.example"),
|
||||
Some("same-origin")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +146,11 @@ impl RpcHandler {
|
||||
.require_fips()
|
||||
.single_delivery()
|
||||
.timeout(std::time::Duration::from_secs(20))
|
||||
.send_content_json(&self.config.data_dir, &binding.contract.seller_did, &body)
|
||||
.send_rental_control_json(
|
||||
&self.config.data_dir,
|
||||
&binding.contract.seller_did,
|
||||
&body,
|
||||
)
|
||||
.await?;
|
||||
anyhow::ensure!(
|
||||
response.status().is_success(),
|
||||
|
||||
@@ -35,6 +35,23 @@ use tokio::net::UdpSocket;
|
||||
/// path filter can restrict the exposed surface.
|
||||
pub const PEER_PORT: u16 = 5679;
|
||||
|
||||
fn is_rental_control_route(path: &str) -> bool {
|
||||
let parts: Vec<_> = path.split('/').collect();
|
||||
let valid_id = |value: &str| {
|
||||
!value.is_empty()
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
|
||||
};
|
||||
parts.len() == 6
|
||||
&& parts[0].is_empty()
|
||||
&& parts[1] == "content"
|
||||
&& valid_id(parts[2])
|
||||
&& parts[3] == "rental"
|
||||
&& valid_id(parts[4])
|
||||
&& matches!(parts[5], "prepare" | "start")
|
||||
}
|
||||
|
||||
/// Whether a FIPS-side HTTP status should trigger a fall-back to Tor in
|
||||
/// `Auto` mode. A `404` over FIPS often means the peer's mesh listener
|
||||
/// doesn't expose that path (e.g. a peer on an older build with a stricter
|
||||
@@ -527,8 +544,27 @@ impl<'a> PeerRequest<'a> {
|
||||
request.send_encoded_json(&encoded).await
|
||||
}
|
||||
|
||||
/// Rental preparation/start carries an already-settled capability, never a
|
||||
/// payment token. Authenticate the exact control route without relaxing the
|
||||
/// purchase-only guard above or replaying any purchase on a retry.
|
||||
pub(crate) async fn send_rental_control_json<B: serde::Serialize>(
|
||||
self,
|
||||
data_dir: &std::path::Path,
|
||||
expected_seller: &str,
|
||||
body: &B,
|
||||
) -> Result<(reqwest::Response, crate::transport::TransportKind)> {
|
||||
anyhow::ensure!(
|
||||
is_rental_control_route(self.path),
|
||||
"Not a rental control route"
|
||||
);
|
||||
let (request, encoded) = self
|
||||
.prepare_authenticated_content_json(data_dir, expected_seller, body)
|
||||
.await?;
|
||||
request.send_encoded_json(&encoded).await
|
||||
}
|
||||
|
||||
async fn prepare_content_json<B: serde::Serialize>(
|
||||
mut self,
|
||||
self,
|
||||
data_dir: &std::path::Path,
|
||||
expected_seller: &str,
|
||||
body: &B,
|
||||
@@ -537,6 +573,16 @@ impl<'a> PeerRequest<'a> {
|
||||
self.path.starts_with("/content/purchase/"),
|
||||
"Not a purchase route"
|
||||
);
|
||||
self.prepare_authenticated_content_json(data_dir, expected_seller, body)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn prepare_authenticated_content_json<B: serde::Serialize>(
|
||||
mut self,
|
||||
data_dir: &std::path::Path,
|
||||
expected_seller: &str,
|
||||
body: &B,
|
||||
) -> Result<(Self, Vec<u8>)> {
|
||||
let peer = crate::federation::load_unique_payment_peer(data_dir, self.onion_host).await?;
|
||||
anyhow::ensure!(
|
||||
peer.did == expected_seller,
|
||||
@@ -887,6 +933,28 @@ impl<'a> PeerRequest<'a> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rental_control_routes_are_exact_and_cannot_be_purchase_routes() {
|
||||
assert!(is_rental_control_route(
|
||||
"/content/registered_abc/rental/1234-5678/prepare"
|
||||
));
|
||||
assert!(is_rental_control_route(
|
||||
"/content/registered_abc/rental/1234-5678/start"
|
||||
));
|
||||
for path in [
|
||||
"/content/purchase/abc",
|
||||
"/content/registered_abc/rental/1234-5678/settle",
|
||||
"/content/registered_abc/rental/1234-5678/prepare/extra",
|
||||
"/content/../rental/1234-5678/prepare",
|
||||
"/content/registered_abc/rental/1234-5678/prepare?token=x",
|
||||
] {
|
||||
assert!(
|
||||
!is_rental_control_route(path),
|
||||
"unexpected rental control route: {path}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn purchase_post_serializes_once_and_binds_the_actual_bytes_to_the_peer() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
@@ -1794,3 +1794,28 @@ isolated runner. The full isolated suite, production backend build, another
|
||||
explicit native update, post-update runtime/data proofs, and paired browser
|
||||
acceptance remain pending. No wallet, payment, personal-media or profile changes
|
||||
are part of this readiness correction.
|
||||
|
||||
## 2026-10-09: two-node IndeeHub UAT — open acceptance items
|
||||
|
||||
Yaya and Framework now have matching UAT frontend bundles, but this is **not**
|
||||
feature acceptance or release publication. The backstage `indeehub.com/watch/`
|
||||
slug control has been removed from the active bundle. The local My List button
|
||||
now calls the library API instead of only toggling its icon; signed-in browser
|
||||
acceptance is pending. Cross-node saves remain open because the library API is
|
||||
local-project-only. Cross-node comments, reactions and zaps remain open: social
|
||||
events use node-origin-specific content IDs and each `/relay` requires that
|
||||
node's login. Do not treat cross-node public film listing as social federation.
|
||||
|
||||
Framework logged `content.playback-prepare: Not a purchase route` after a Cashu
|
||||
rental attempt. The buyer sent a rental-control path through a purchase-only
|
||||
transport guard. A narrow authenticated rental-control route is implemented;
|
||||
the release binary compiled, its route unit test passed through the isolated
|
||||
runner, and the identical binary is deployed on both UAT nodes with healthy
|
||||
RPC and session services. An existing-purchase retry without another payment
|
||||
remains pending. The earlier
|
||||
Framework LND startup fix does not close this paid-file recovery incident.
|
||||
Lightning purchase/zap still lacks configured BTCPay credentials on both UAT
|
||||
nodes; the API now reports service unavailable instead of an opaque Invalid URL,
|
||||
but payment is not accepted. Framework's listed film has no verified media
|
||||
registration or signed stream offer; listing is not proof of playable media.
|
||||
No OTA, ISO or public release follows from these UAT changes.
|
||||
|
||||
@@ -37,6 +37,17 @@ map "$archy_management_private_source:$archy_management_private_client:$archy_ma
|
||||
}
|
||||
# END ARCHIPELAGO MANAGEMENT SOURCE GUARD
|
||||
|
||||
# Only IndeeHub on this same node may read the owner's FileBrowser API for
|
||||
# its in-app Cloud picker. Never reflect arbitrary cross-port origins.
|
||||
map "$host|$http_origin" $archy_cloud_picker_origin {
|
||||
default "";
|
||||
"~^([^|]+)\|http://\1:7778$" $http_origin;
|
||||
}
|
||||
map "$request_method|$http_sec_fetch_site" $archy_terminal_origin {
|
||||
default $http_origin;
|
||||
"GET|same-origin" "$scheme://$http_host";
|
||||
}
|
||||
|
||||
# Rate limit zones
|
||||
limit_req_zone $binary_remote_addr zone=rpc:10m rate=20r/s;
|
||||
limit_req_zone $binary_remote_addr zone=auth:10m rate=3r/s;
|
||||
@@ -287,6 +298,7 @@ server {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_set_header Origin $archy_terminal_origin;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
@@ -326,6 +338,12 @@ server {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
# Browser-mediated Cloud selection uses the existing node session.
|
||||
# Only this node's own IndeeHub origin is admitted by the map above.
|
||||
proxy_hide_header Access-Control-Allow-Origin;
|
||||
proxy_hide_header Access-Control-Allow-Credentials;
|
||||
add_header Access-Control-Allow-Origin $archy_cloud_picker_origin always;
|
||||
add_header Access-Control-Allow-Credentials "true" always;
|
||||
|
||||
# Limit request body to 1MB for RPC calls
|
||||
client_max_body_size 1m;
|
||||
@@ -390,6 +408,24 @@ server {
|
||||
error_page 504 = @backend_timeout;
|
||||
}
|
||||
|
||||
# Public, sanitized IndeeHub listings only. The upstream is the direct
|
||||
# loopback app port; video, keys and payments remain behind the app gate.
|
||||
location = /api/public-catalog/indeedhub {
|
||||
limit_except GET { deny all; }
|
||||
proxy_pass http://127.0.0.1:7778/api/projects/public-catalog;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Cookie "";
|
||||
proxy_set_header Authorization "";
|
||||
proxy_hide_header Access-Control-Allow-Origin;
|
||||
proxy_hide_header Access-Control-Allow-Credentials;
|
||||
proxy_hide_header Cache-Control;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 10s;
|
||||
add_header Access-Control-Allow-Origin "*" always;
|
||||
add_header Cache-Control "public, max-age=30" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
}
|
||||
|
||||
# Pine node status — live node facts for the Pine launcher page and the
|
||||
# seeded Home Assistant sensors. Sensitive fields are token-gated at the
|
||||
# backend; nginx only forwards.
|
||||
@@ -614,6 +650,8 @@ server {
|
||||
proxy_hide_header X-Frame-Options;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
add_header Access-Control-Allow-Origin $archy_cloud_picker_origin always;
|
||||
add_header Access-Control-Allow-Credentials "true" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
proxy_request_buffering off;
|
||||
proxy_set_header Accept-Encoding "";
|
||||
@@ -1274,6 +1312,7 @@ server {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_set_header Origin $archy_terminal_origin;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
@@ -1326,6 +1365,24 @@ server {
|
||||
error_page 504 = @backend_timeout;
|
||||
}
|
||||
|
||||
# Public, sanitized IndeeHub listings only. The upstream is the direct
|
||||
# loopback app port; video, keys and payments remain behind the app gate.
|
||||
location = /api/public-catalog/indeedhub {
|
||||
limit_except GET { deny all; }
|
||||
proxy_pass http://127.0.0.1:7778/api/projects/public-catalog;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Cookie "";
|
||||
proxy_set_header Authorization "";
|
||||
proxy_hide_header Access-Control-Allow-Origin;
|
||||
proxy_hide_header Access-Control-Allow-Credentials;
|
||||
proxy_hide_header Cache-Control;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 10s;
|
||||
add_header Access-Control-Allow-Origin "*" always;
|
||||
add_header Cache-Control "public, max-age=30" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
}
|
||||
|
||||
# Pine node status — live node facts for the Pine launcher page and the
|
||||
# seeded Home Assistant sensors. Sensitive fields are token-gated at the
|
||||
# backend; nginx only forwards.
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
<template>
|
||||
<div v-if="request" class="absolute inset-0 z-[81] flex items-center justify-center bg-black/70 p-3">
|
||||
<section ref="modal" role="dialog" aria-modal="true" aria-label="Confirm video rental" :aria-busy="busy" class="glass-card max-h-[90%] w-full max-w-md overflow-y-auto rounded-2xl p-5 text-white">
|
||||
<p class="text-xs text-white/50">IndeeHub · Your node wallet</p>
|
||||
<h2 class="mt-2 break-words text-xl font-semibold">{{ request.title }}</h2>
|
||||
<dl class="my-4 space-y-2 text-sm"><div>Rental: {{ request.terms.priceSats }} sats</div><div>Viewing period: {{ Math.ceil(request.terms.viewingSeconds / 60) }} minutes after you press Start</div><template v-if="quote"><div>Payment: Cashu · {{ quote.network === 'testnet' ? 'Testnet' : 'Mainnet' }}</div><div class="break-all">Mint: {{ quote.mint_url }}</div></template><div v-if="quote" class="font-semibold">Total wallet debit: {{ quote.wallet_debit_sats }} sats</div></dl>
|
||||
<p class="text-sm text-white/60">Review the exact wallet debit before paying. If a response is lost, reopen this title to recover the same purchase.</p>
|
||||
<p v-if="error" role="alert" class="mt-3 break-words text-sm text-red-200">{{ error }}</p>
|
||||
<p v-if="busy" role="status" class="mt-4 text-sm">{{ phase === 'preparing' ? progress : phase === 'paying' ? 'Recovering or completing your payment…' : 'Checking the original purchase and current fees…' }}</p>
|
||||
<button type="button" v-if="quote" :disabled="busy" class="mt-3 min-h-11 w-full rounded-lg border border-white/20 text-sm disabled:opacity-40" @click="$emit('cancelUnpaid')">Cancel unpaid quote</button>
|
||||
<footer class="mt-5 flex flex-col gap-2 sm:flex-row"><button type="button" class="glass-button min-h-11 flex-1 rounded-lg px-3" @click="$emit('cancel')">Close</button><button type="button" :disabled="busy" class="glass-button min-h-11 flex-1 rounded-lg px-3 text-orange-200 disabled:opacity-40" @click="phase === 'confirm' ? $emit('approve') : $emit('review')">{{ phase === 'confirm' ? `Pay ${quote?.wallet_debit_sats} sats` : 'Check purchase' }}</button></footer>
|
||||
<div v-if="request" class="absolute inset-0 z-[81] flex items-center justify-center bg-black/80 p-3 backdrop-blur-sm">
|
||||
<section ref="modal" role="dialog" aria-modal="true" aria-label="Confirm video rental" :aria-busy="busy" class="glass-card max-h-[90%] w-full max-w-lg overflow-y-auto rounded-2xl border border-white/15 p-5 text-white shadow-2xl sm:p-7">
|
||||
<header class="flex items-start gap-3">
|
||||
<div class="min-w-0 flex-1"><p class="text-xs uppercase tracking-widest text-white/45">IndeeHub · Node rental</p><h2 class="mt-2 break-words text-2xl font-bold">{{ request.title }}</h2></div>
|
||||
<button type="button" aria-label="Close rental" class="min-h-11 min-w-11 rounded-lg text-white/60 hover:bg-white/10 hover:text-white" @click="$emit('cancel')">✕</button>
|
||||
</header>
|
||||
<div class="my-6 rounded-xl border border-white/10 bg-white/5 p-5">
|
||||
<div class="flex items-start justify-between gap-4"><div><h3 class="text-lg font-semibold">Rental details</h3><p class="mt-1 text-sm text-white/60">{{ Math.ceil(request.terms.viewingSeconds / 60) }} minutes from when you press Start viewing</p></div><div class="shrink-0 text-right"><p class="text-2xl font-bold">{{ request.terms.priceSats.toLocaleString() }} <span class="text-sm font-normal text-white/55">sats</span></p><p class="text-xs text-white/50">Film price</p></div></div>
|
||||
<div v-if="quote" class="mt-5 space-y-2 border-t border-white/10 pt-4 text-sm"><div class="flex justify-between gap-3"><span class="text-white/60">Payment method</span><span>Node wallet · Cashu</span></div><div class="flex justify-between gap-3"><span class="text-white/60">Network</span><span>{{ quote.network === 'testnet' ? 'Testnet' : 'Mainnet' }}</span></div><div class="flex justify-between gap-3 border-t border-white/10 pt-3 font-semibold"><span>Total wallet debit, including fees</span><span>{{ quote.wallet_debit_sats.toLocaleString() }} sats</span></div><details class="pt-2 text-xs text-white/45"><summary class="cursor-pointer">Mint details</summary><p class="mt-2 break-all">{{ quote.mint_url }}</p></details></div>
|
||||
</div>
|
||||
<p class="text-sm leading-relaxed text-white/60">No viewing time starts until you press Start viewing. Reopening this title recovers the same purchase; it must not charge you twice.</p>
|
||||
<p v-if="error" role="alert" class="mt-4 rounded-lg border border-red-400/25 bg-red-500/10 p-3 break-words text-sm text-red-200">{{ error }}</p>
|
||||
<p v-if="busy" role="status" class="mt-4 text-sm text-white/70">{{ phase === 'preparing' ? progress : phase === 'paying' ? 'Completing or recovering the original payment…' : 'Checking the original purchase and current fees…' }}</p>
|
||||
<button type="button" v-if="quote" :disabled="busy" class="mt-4 min-h-11 w-full rounded-lg border border-white/20 text-sm text-white/70 disabled:opacity-40" @click="$emit('cancelUnpaid')">Cancel unpaid quote</button>
|
||||
<footer class="mt-6 flex flex-col-reverse gap-3 sm:flex-row"><button type="button" class="glass-button min-h-11 flex-1 rounded-lg px-4" @click="$emit('cancel')">Close</button><button type="button" :disabled="busy" class="glass-button min-h-11 flex-1 rounded-lg border border-orange-400/30 px-4 font-semibold text-orange-200 disabled:opacity-40" @click="phase === 'confirm' ? $emit('approve') : $emit('review')">{{ phase === 'confirm' ? `Pay ${quote?.wallet_debit_sats} sats from node wallet` : 'Check purchase' }}</button></footer>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -166,24 +166,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto shrink-0 pt-4 space-y-3">
|
||||
<!-- Always the bottom 50/50 pair, every screen size — consistent with
|
||||
the other web5 cards' full-width bottom actions. -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
@click="router.push('/dashboard/server/federation')"
|
||||
class="mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
{{ t('web5.findNodes') }}
|
||||
<div class="mt-auto shrink-0 pt-4 border-t border-white/10">
|
||||
<nav aria-label="Node connections" class="grid grid-cols-[repeat(2,minmax(0,1fr))_2.75rem] sm:grid-cols-[repeat(3,minmax(0,1fr))_2.75rem] gap-2">
|
||||
<button @click="router.push({ name: 'federation', query: { view: 'connected' } })" class="mobile-card-action glass-button rounded-lg px-2 text-xs sm:text-sm font-medium text-white/90 hover:text-white text-center min-h-11">
|
||||
My connections
|
||||
</button>
|
||||
<button @click="router.push({ name: 'federation', query: { view: 'discover' } })" class="mobile-card-action glass-button rounded-lg px-2 text-xs sm:text-sm font-medium text-white/90 hover:text-white text-center min-h-11">
|
||||
Find Nodes
|
||||
</button>
|
||||
<button @click="router.push({ name: 'federation', query: { view: 'map' } })" class="mobile-card-action glass-button rounded-lg px-2 text-xs sm:text-sm font-medium text-white/90 hover:text-white text-center min-h-11 col-span-2 sm:col-span-1">
|
||||
Map
|
||||
</button>
|
||||
<button
|
||||
@click="refreshActiveTab"
|
||||
:disabled="loadingPeers || loadingRequests"
|
||||
class="mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors"
|
||||
class="mobile-card-action glass-button rounded-lg min-h-11 w-11 flex items-center justify-center text-white/80 hover:text-white disabled:opacity-50"
|
||||
:aria-label="loadingPeers || loadingRequests ? 'Refreshing connected nodes' : 'Refresh connected nodes'"
|
||||
title="Refresh connected nodes"
|
||||
>
|
||||
{{ loadingPeers || loadingRequests ? t('common.loading') : t('common.refresh') }}
|
||||
<svg class="w-4 h-4" :class="{ 'animate-spin': loadingPeers || loadingRequests }" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 11a8 8 0 10-2.4 6M20 4v7h-7" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
<template>
|
||||
<!-- Quick Actions Container -->
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<div class="flex flex-wrap gap-3 mb-4" aria-label="Node connections">
|
||||
<RouterLink :to="{ name: 'federation', query: { view: 'discover' } }" class="glass-button px-4 py-2 rounded-lg text-sm text-white">Connect with Nodes</RouterLink>
|
||||
<RouterLink :to="{ name: 'federation', query: { view: 'connected' } }" class="glass-button px-4 py-2 rounded-lg text-sm text-white">Connected Nodes</RouterLink>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-5 gap-4 stagger-grid">
|
||||
<!-- Networking Profits -->
|
||||
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 0">
|
||||
@@ -186,7 +182,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { ProfitsData, NostrRelayStatsData, HwWalletDevice } from './types'
|
||||
|
||||
|
||||
@@ -5,8 +5,9 @@ import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
const pushRoute = vi.hoisted(() => vi.fn())
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
useRouter: () => ({ push: pushRoute }),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
@@ -47,6 +48,19 @@ describe('Web5ConnectedNodes', () => {
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
it('puts the three federation destinations and icon refresh in the bottom bar', async () => {
|
||||
const wrapper = mount(Web5ConnectedNodes)
|
||||
const nav = wrapper.get('nav[aria-label="Node connections"]')
|
||||
expect(nav.text()).toContain('My connections')
|
||||
expect(nav.text()).toContain('Find Nodes')
|
||||
expect(nav.text()).toContain('Map')
|
||||
await nav.findAll('button')[0]!.trigger('click')
|
||||
await nav.findAll('button')[1]!.trigger('click')
|
||||
await nav.findAll('button')[2]!.trigger('click')
|
||||
expect(pushRoute.mock.calls.map(([route]) => route.query.view)).toEqual(['connected', 'discover', 'map'])
|
||||
expect(nav.findAll('button')[3]!.attributes('aria-label')).toBe('Refresh connected nodes')
|
||||
})
|
||||
|
||||
it('shows a loading state for empty trusted nodes while peers are loading', async () => {
|
||||
const wrapper = mount(Web5ConnectedNodes)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user