security+feat: v1.3.0 — pentest remediation, container reliability, UI overhaul
Security (33 pentest findings addressed): - CRITICAL: backend binds 127.0.0.1, path traversal in tor.rs/dwn fixed - HIGH: federation requires signatures, XSS login redirect, RBAC viewer restricted - HIGH: tar slip prevention, S3 SSRF validation, backup ID validation - MEDIUM: remember-me random secret, TOTP session rotation, password re-auth - LOW: CSP unsafe-inline removed, CORS dev-only, onion/webhook validation Container reliability: - Memory limits on all 37 containers (OOM prevention) - Exited vs stopped state distinction with health-aware status badges - Crash recovery coordination (no more restart cascade) - User-stopped tracking survives reboots - Tiered boot recovery (databases → core → services → apps) UI: - Wallet TransactionsModal, health-aware app status badges - Restart button on containers, exited/crashed red state - Mesh view overhaul, glass button updates, BaseModal/ToggleSwitch - Apps sticky header removed, dev faucet, mutable mock wallet Infrastructure: - LND REST port 8080 exposed over Tor (LND Connect fix) - Nginx cookie_session fix, deploy script Tor config updated - Dev environment: podman auto-start, boot mode simulation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,11 +4,21 @@ import { useMeshStore } from '@/stores/mesh'
|
||||
import { useTransportStore } from '@/stores/transport'
|
||||
import type { MeshPeer, SessionStatus } from '@/stores/mesh'
|
||||
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
||||
import ToggleSwitch from '@/components/ToggleSwitch.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const mesh = useMeshStore()
|
||||
const transport = useTransportStore()
|
||||
|
||||
// Responsive layout breakpoints
|
||||
const isWideDesktop = ref(window.innerWidth >= 1536)
|
||||
const isMobile = ref(window.innerWidth < 1280)
|
||||
|
||||
function handleResize() {
|
||||
isWideDesktop.value = window.innerWidth >= 1536
|
||||
isMobile.value = window.innerWidth < 1280
|
||||
}
|
||||
|
||||
// Active chat: either a peer or a channel
|
||||
const activeChatPeer = ref<MeshPeer | null>(null)
|
||||
const activeChatChannel = ref<{ index: number; name: string } | null>(null)
|
||||
@@ -18,6 +28,7 @@ const broadcasting = ref(false)
|
||||
const configuring = ref(false)
|
||||
const connectingDevice = ref<string | null>(null)
|
||||
const chatScrollEl = ref<HTMLElement | null>(null)
|
||||
const mobileShowChat = ref(false)
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// The Public channel (always available on Meshcore)
|
||||
@@ -43,6 +54,27 @@ const deadmanInterval = ref('21600')
|
||||
const deadmanEnabled = ref(false)
|
||||
const deadmanCustomMsg = ref('')
|
||||
|
||||
// Tools tab for 3rd column on wide desktop and mobile below-chat
|
||||
const toolsTab = ref<'bitcoin' | 'deadman'>('bitcoin')
|
||||
|
||||
// Panel visibility computeds
|
||||
const showChatPanel = computed(() =>
|
||||
activeTab.value === 'chat' || isWideDesktop.value || (isMobile.value && mobileShowChat.value)
|
||||
)
|
||||
// On wide desktop + mobile first view: tools use their own tab bar
|
||||
const showBitcoinPanel = computed(() => {
|
||||
if (isWideDesktop.value || (isMobile.value && !mobileShowChat.value)) return toolsTab.value === 'bitcoin'
|
||||
return activeTab.value === 'bitcoin'
|
||||
})
|
||||
const showDeadmanPanel = computed(() => {
|
||||
if (isWideDesktop.value || (isMobile.value && !mobileShowChat.value)) return toolsTab.value === 'deadman'
|
||||
return activeTab.value === 'deadman'
|
||||
})
|
||||
// Mobile tools: show on first view (peers), hide when in chat
|
||||
const showMobileTools = computed(() => isMobile.value && !mobileShowChat.value)
|
||||
// Medium desktop: show 3-tab bar. Wide + mobile: hidden (tools has own tab bar)
|
||||
const showTabBar = computed(() => !isWideDesktop.value && !isMobile.value)
|
||||
|
||||
// Fetch session status when active peer changes
|
||||
watch(() => activeChatPeer.value, async (peer) => {
|
||||
if (peer) {
|
||||
@@ -184,6 +216,7 @@ function formatTimeRemaining(secs: number): string {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('resize', handleResize)
|
||||
await Promise.all([mesh.refreshAll(), transport.fetchStatus()])
|
||||
// Sync deadman UI state from server
|
||||
if (mesh.deadmanStatus) {
|
||||
@@ -200,6 +233,7 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
if (pollInterval) clearInterval(pollInterval)
|
||||
})
|
||||
|
||||
@@ -253,6 +287,7 @@ function openChat(peer: MeshPeer) {
|
||||
sendError.value = ''
|
||||
messageText.value = ''
|
||||
activeTab.value = 'chat'
|
||||
mobileShowChat.value = true
|
||||
mesh.markChatRead(peer.contact_id)
|
||||
nextTick(() => scrollChatToBottom())
|
||||
}
|
||||
@@ -263,12 +298,14 @@ function openChannelChat(channel: { index: number; name: string }) {
|
||||
sendError.value = ''
|
||||
messageText.value = ''
|
||||
activeTab.value = 'chat'
|
||||
mobileShowChat.value = true
|
||||
nextTick(() => scrollChatToBottom())
|
||||
}
|
||||
|
||||
function closeChat() {
|
||||
activeChatPeer.value = null
|
||||
activeChatChannel.value = null
|
||||
mobileShowChat.value = false
|
||||
mesh.clearViewingChat()
|
||||
}
|
||||
|
||||
@@ -360,10 +397,10 @@ function truncatePubkey(hex: string | null): string {
|
||||
<!-- Error banner -->
|
||||
<div v-if="mesh.error" class="mesh-error">{{ mesh.error }}</div>
|
||||
|
||||
<!-- Two-column layout (desktop) / single-column (mobile) -->
|
||||
<div class="mesh-columns">
|
||||
<!-- Responsive column layout: 3-col (wide), 2-col (medium), 1-col (mobile) -->
|
||||
<div class="mesh-columns" :class="{ 'mesh-columns-wide': isWideDesktop }">
|
||||
<!-- LEFT COLUMN: Status + Peers -->
|
||||
<div class="mesh-left">
|
||||
<div class="mesh-left" :class="{ 'mobile-hidden': mobileShowChat }">
|
||||
<!-- Device Status -->
|
||||
<div class="glass-card mesh-status-card">
|
||||
<div class="mesh-status-header">
|
||||
@@ -499,9 +536,9 @@ function truncatePubkey(hex: string | null): string {
|
||||
</div>
|
||||
|
||||
<!-- RIGHT COLUMN: Tabbed panels -->
|
||||
<div class="mesh-right">
|
||||
<!-- Tab bar -->
|
||||
<div class="mesh-tab-bar">
|
||||
<div class="mesh-right" :class="{ 'mobile-hidden': !mobileShowChat }">
|
||||
<!-- Tab bar (medium desktop only) -->
|
||||
<div v-if="showTabBar" class="mesh-tab-bar">
|
||||
<button class="mesh-tab" :class="{ active: activeTab === 'chat' }" @click="activeTab = 'chat'">Chat</button>
|
||||
<button class="mesh-tab" :class="{ active: activeTab === 'bitcoin' }" @click="activeTab = 'bitcoin'">
|
||||
Off-Grid Bitcoin
|
||||
@@ -513,146 +550,8 @@ function truncatePubkey(hex: string | null): string {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Off-Grid Bitcoin Panel -->
|
||||
<div v-if="activeTab === 'bitcoin'" class="glass-card mesh-bitcoin-panel">
|
||||
<h3 class="mesh-panel-title">Off-Grid Bitcoin</h3>
|
||||
<p class="mesh-panel-sub">Relay transactions and receive block headers via mesh radio</p>
|
||||
|
||||
<!-- Relay status notification -->
|
||||
<div v-if="relayResult" class="mesh-relay-result" :class="relayResult.includes('failed') || relayResult.includes('Failed') ? 'error' : 'success'">
|
||||
{{ relayResult }}
|
||||
</div>
|
||||
|
||||
<!-- Block Headers -->
|
||||
<div class="mesh-bitcoin-section">
|
||||
<div class="mesh-bitcoin-section-header">
|
||||
<span class="mesh-bitcoin-label">Latest Block</span>
|
||||
<span v-if="mesh.latestBlockHeight > 0" class="mesh-bitcoin-height">#{{ mesh.latestBlockHeight.toLocaleString() }}</span>
|
||||
<span v-else class="mesh-bitcoin-height mesh-muted">No headers yet</span>
|
||||
</div>
|
||||
<div v-if="mesh.blockHeaders.length" class="mesh-block-list">
|
||||
<div v-for="h in mesh.blockHeaders.slice(0, 2)" :key="h.height" class="mesh-block-row">
|
||||
<span class="mesh-block-height">#{{ h.height.toLocaleString() }}</span>
|
||||
<span class="mesh-block-hash">{{ h.hash.slice(0, 12) }}...{{ h.hash.slice(-8) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- On-Chain / Lightning tabs -->
|
||||
<div class="mesh-send-tabs">
|
||||
<button class="mesh-send-tab" :class="{ active: sendTab === 'onchain' }" @click="sendTab = 'onchain'">On-Chain</button>
|
||||
<button class="mesh-send-tab" :class="{ active: sendTab === 'lightning' }" @click="sendTab = 'lightning'">Lightning</button>
|
||||
</div>
|
||||
|
||||
<!-- On-Chain tab -->
|
||||
<div v-if="sendTab === 'onchain'" class="mesh-bitcoin-section">
|
||||
<p class="mesh-bitcoin-hint">Creates a signed transaction locally and relays via mesh peers</p>
|
||||
<input v-model="meshSendAddr" class="mesh-bitcoin-input" placeholder="Bitcoin address (bc1...)" />
|
||||
<input v-model="meshSendAmount" class="mesh-bitcoin-input mesh-bitcoin-input-sm" type="number" placeholder="Amount (sats)" min="546" />
|
||||
<div class="mesh-relay-mode">
|
||||
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'archy' }">
|
||||
<input type="radio" v-model="relayMode" value="archy" />
|
||||
<span>Archy Peers <small>(E2E encrypted, direct)</small></span>
|
||||
</label>
|
||||
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'broadcast' }">
|
||||
<input type="radio" v-model="relayMode" value="broadcast" />
|
||||
<span>Mesh Broadcast <small>(multi-hop, wider reach)</small></span>
|
||||
</label>
|
||||
</div>
|
||||
<button class="glass-button" :disabled="!meshSendAddr.trim() || !meshSendAmount || relayingTx" @click="handleMeshSendBitcoin">
|
||||
{{ relayingTx ? 'Sending...' : 'Send via Mesh' }}
|
||||
</button>
|
||||
<details class="mesh-bitcoin-advanced">
|
||||
<summary class="mesh-bitcoin-label">Raw TX Relay</summary>
|
||||
<div style="margin-top: 8px;">
|
||||
<textarea v-model="txHexInput" class="mesh-bitcoin-input" placeholder="Paste raw transaction hex..." rows="3" />
|
||||
<button class="glass-button" :disabled="!txHexInput.trim() || relayingTx" @click="handleRelayTx">
|
||||
{{ relayingTx ? 'Relaying...' : 'Relay Raw TX' }}
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Lightning tab -->
|
||||
<div v-if="sendTab === 'lightning'" class="mesh-bitcoin-section">
|
||||
<p class="mesh-bitcoin-hint">Relays a Lightning invoice to an internet-connected peer for payment</p>
|
||||
<input v-model="bolt11Input" class="mesh-bitcoin-input" placeholder="lnbc... (bolt11 invoice)" />
|
||||
<input v-model="bolt11AmountInput" class="mesh-bitcoin-input mesh-bitcoin-input-sm" type="number" placeholder="Amount (sats)" />
|
||||
<div class="mesh-relay-mode">
|
||||
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'archy' }">
|
||||
<input type="radio" v-model="relayMode" value="archy" />
|
||||
<span>Archy Peers <small>(E2E encrypted, direct)</small></span>
|
||||
</label>
|
||||
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'broadcast' }">
|
||||
<input type="radio" v-model="relayMode" value="broadcast" />
|
||||
<span>Mesh Broadcast <small>(multi-hop, wider reach)</small></span>
|
||||
</label>
|
||||
</div>
|
||||
<button class="glass-button" :disabled="!bolt11Input.trim() || !bolt11AmountInput || relayingLn" @click="handleRelayLightning">
|
||||
{{ relayingLn ? 'Relaying...' : 'Pay via Mesh' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Dead Man's Switch Panel -->
|
||||
<div v-if="activeTab === 'deadman'" class="glass-card mesh-deadman-panel">
|
||||
<h3 class="mesh-panel-title">Dead Man's Switch</h3>
|
||||
<p class="mesh-panel-sub">Auto-broadcasts a signed emergency alert if you don't check in</p>
|
||||
|
||||
<!-- Status -->
|
||||
<div v-if="mesh.deadmanStatus" class="mesh-deadman-status">
|
||||
<div class="mesh-deadman-indicator" :class="mesh.deadmanStatus.triggered ? 'triggered' : mesh.deadmanStatus.dead_man_enabled ? 'armed' : 'disabled'">
|
||||
{{ mesh.deadmanStatus.triggered ? 'TRIGGERED' : mesh.deadmanStatus.dead_man_enabled ? 'ARMED' : 'DISABLED' }}
|
||||
</div>
|
||||
<div v-if="mesh.deadmanStatus.dead_man_enabled && !mesh.deadmanStatus.triggered" class="mesh-deadman-timer">
|
||||
{{ formatTimeRemaining(mesh.deadmanStatus.time_remaining_secs) }}
|
||||
</div>
|
||||
<div v-if="deadmanCustomMsg || mesh.deadmanStatus.dead_man_enabled" class="mesh-deadman-message">
|
||||
{{ deadmanCustomMsg || 'Dead man\'s switch triggered — operator unresponsive' }}
|
||||
</div>
|
||||
<button v-if="mesh.deadmanStatus.dead_man_enabled" class="glass-button mesh-deadman-checkin-btn" @click="handleDeadmanCheckin">
|
||||
I'm OK — Check In
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Configuration -->
|
||||
<div class="mesh-deadman-config">
|
||||
<label class="mesh-deadman-toggle">
|
||||
<input v-model="deadmanEnabled" type="checkbox" @change="handleDeadmanToggle" />
|
||||
<span>{{ deadmanEnabled ? 'Dead Man\'s Switch Active' : 'Enable Dead Man\'s Switch' }}</span>
|
||||
</label>
|
||||
|
||||
<template v-if="deadmanEnabled">
|
||||
<div class="mesh-deadman-field">
|
||||
<label class="mesh-bitcoin-label">Trigger Interval</label>
|
||||
<select v-model="deadmanInterval" class="mesh-bitcoin-input mesh-bitcoin-input-sm">
|
||||
<option value="3600">1 hour</option>
|
||||
<option value="21600">6 hours</option>
|
||||
<option value="43200">12 hours</option>
|
||||
<option value="86400">24 hours</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mesh-deadman-field">
|
||||
<label class="mesh-bitcoin-label">Alert Message</label>
|
||||
<input v-model="deadmanCustomMsg" class="mesh-bitcoin-input" placeholder="Dead man's switch triggered — operator unresponsive" />
|
||||
</div>
|
||||
|
||||
<div class="mesh-deadman-info">
|
||||
<span v-if="mesh.deadmanStatus?.has_gps" class="mesh-deadman-info-item">GPS: included</span>
|
||||
<span class="mesh-deadman-info-item">Contacts: {{ mesh.deadmanStatus?.emergency_contacts ?? 0 }}</span>
|
||||
</div>
|
||||
|
||||
<button class="glass-button" :disabled="deadmanConfiguring" @click="handleDeadmanConfigure">
|
||||
{{ deadmanConfiguring ? 'Saving...' : 'Save Configuration' }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Panel (existing) -->
|
||||
<div v-if="activeTab === 'chat'" class="glass-card mesh-chat-card">
|
||||
<!-- Chat Panel (before tools so it gets grid-column 2 on wide) -->
|
||||
<div v-if="showChatPanel" class="glass-card mesh-chat-card">
|
||||
<!-- No chat selected -->
|
||||
<div v-if="!hasActiveChat" class="mesh-chat-empty">
|
||||
<div class="mesh-chat-empty-icon">📡</div>
|
||||
@@ -758,12 +657,228 @@ function truncatePubkey(hex: string | null): string {
|
||||
{{ mesh.sending ? '...' : 'Send' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="mesh-chat-compose-meta">
|
||||
<span>{{ messageText.length }}/160</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Off-Grid Bitcoin + Dead Man's Switch panels -->
|
||||
<div class="mesh-tools-wrapper">
|
||||
<!-- Tools tab bar (wide desktop only — mobile has its own outside mesh-right) -->
|
||||
<div v-if="isWideDesktop" class="mesh-tools-tab-bar">
|
||||
<button class="mesh-tab" :class="{ active: toolsTab === 'bitcoin' }" @click="toolsTab = 'bitcoin'">
|
||||
Off-Grid Bitcoin
|
||||
<span v-if="mesh.latestBlockHeight > 0" class="mesh-tab-badge">{{ mesh.latestBlockHeight }}</span>
|
||||
</button>
|
||||
<button class="mesh-tab" :class="{ active: toolsTab === 'deadman' }" @click="toolsTab = 'deadman'">
|
||||
Dead Man
|
||||
<span v-if="mesh.deadmanStatus?.triggered" class="mesh-tab-badge mesh-tab-badge-alert">!</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Off-Grid Bitcoin Panel -->
|
||||
<div v-if="showBitcoinPanel" class="glass-card mesh-bitcoin-panel">
|
||||
<h3 class="mesh-panel-title">Off-Grid Bitcoin</h3>
|
||||
<p class="mesh-panel-sub">Relay transactions and receive block headers via mesh radio</p>
|
||||
|
||||
<!-- Relay status notification -->
|
||||
<div v-if="relayResult" class="mesh-relay-result" :class="relayResult.includes('failed') || relayResult.includes('Failed') ? 'error' : 'success'">
|
||||
{{ relayResult }}
|
||||
</div>
|
||||
|
||||
<!-- Block Headers -->
|
||||
<div class="mesh-bitcoin-section">
|
||||
<div class="mesh-bitcoin-section-header">
|
||||
<span class="mesh-bitcoin-label">Latest Block</span>
|
||||
<span v-if="mesh.latestBlockHeight > 0" class="mesh-bitcoin-height">#{{ mesh.latestBlockHeight.toLocaleString() }}</span>
|
||||
<span v-else class="mesh-bitcoin-height mesh-muted">No headers yet</span>
|
||||
</div>
|
||||
<div v-if="mesh.blockHeaders.length" class="mesh-block-list">
|
||||
<div v-for="h in mesh.blockHeaders.slice(0, 2)" :key="h.height" class="mesh-block-row">
|
||||
<span class="mesh-block-height">#{{ h.height.toLocaleString() }}</span>
|
||||
<span class="mesh-block-hash">{{ h.hash.slice(0, 12) }}...{{ h.hash.slice(-8) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- On-Chain / Lightning tabs -->
|
||||
<div class="mesh-send-tabs">
|
||||
<button class="mesh-send-tab" :class="{ active: sendTab === 'onchain' }" @click="sendTab = 'onchain'">On-Chain</button>
|
||||
<button class="mesh-send-tab" :class="{ active: sendTab === 'lightning' }" @click="sendTab = 'lightning'">Lightning</button>
|
||||
</div>
|
||||
|
||||
<!-- On-Chain tab -->
|
||||
<div v-if="sendTab === 'onchain'" class="mesh-bitcoin-section">
|
||||
<p class="mesh-bitcoin-hint">Creates a signed transaction locally and relays via mesh peers</p>
|
||||
<input v-model="meshSendAddr" class="mesh-bitcoin-input" placeholder="Bitcoin address (bc1...)" />
|
||||
<input v-model="meshSendAmount" class="mesh-bitcoin-input mesh-bitcoin-input-sm" type="number" placeholder="Amount (sats)" min="546" />
|
||||
<div class="mesh-relay-mode">
|
||||
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'archy' }">
|
||||
<input type="radio" v-model="relayMode" value="archy" />
|
||||
<span>Archy Peers <small>(E2E encrypted, direct)</small></span>
|
||||
</label>
|
||||
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'broadcast' }">
|
||||
<input type="radio" v-model="relayMode" value="broadcast" />
|
||||
<span>Mesh Broadcast <small>(multi-hop, wider reach)</small></span>
|
||||
</label>
|
||||
</div>
|
||||
<button class="glass-button" :disabled="!meshSendAddr.trim() || !meshSendAmount || relayingTx" @click="handleMeshSendBitcoin">
|
||||
{{ relayingTx ? 'Sending...' : 'Send via Mesh' }}
|
||||
</button>
|
||||
<details class="mesh-bitcoin-advanced">
|
||||
<summary class="mesh-bitcoin-label">Raw TX Relay</summary>
|
||||
<div style="margin-top: 8px;">
|
||||
<textarea v-model="txHexInput" class="mesh-bitcoin-input" placeholder="Paste raw transaction hex..." rows="3" />
|
||||
<button class="glass-button" :disabled="!txHexInput.trim() || relayingTx" @click="handleRelayTx">
|
||||
{{ relayingTx ? 'Relaying...' : 'Relay Raw TX' }}
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Lightning tab -->
|
||||
<div v-if="sendTab === 'lightning'" class="mesh-bitcoin-section">
|
||||
<p class="mesh-bitcoin-hint">Relays a Lightning invoice to an internet-connected peer for payment</p>
|
||||
<input v-model="bolt11Input" class="mesh-bitcoin-input" placeholder="lnbc... (bolt11 invoice)" />
|
||||
<input v-model="bolt11AmountInput" class="mesh-bitcoin-input mesh-bitcoin-input-sm" type="number" placeholder="Amount (sats)" />
|
||||
<div class="mesh-relay-mode">
|
||||
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'archy' }">
|
||||
<input type="radio" v-model="relayMode" value="archy" />
|
||||
<span>Archy Peers <small>(E2E encrypted, direct)</small></span>
|
||||
</label>
|
||||
<label class="mesh-relay-mode-option" :class="{ active: relayMode === 'broadcast' }">
|
||||
<input type="radio" v-model="relayMode" value="broadcast" />
|
||||
<span>Mesh Broadcast <small>(multi-hop, wider reach)</small></span>
|
||||
</label>
|
||||
</div>
|
||||
<button class="glass-button" :disabled="!bolt11Input.trim() || !bolt11AmountInput || relayingLn" @click="handleRelayLightning">
|
||||
{{ relayingLn ? 'Relaying...' : 'Pay via Mesh' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Dead Man's Switch Panel -->
|
||||
<div v-if="showDeadmanPanel" class="glass-card mesh-deadman-panel">
|
||||
<h3 class="mesh-panel-title">Dead Man's Switch</h3>
|
||||
<p class="mesh-panel-sub">Auto-broadcasts a signed emergency alert if you don't check in</p>
|
||||
|
||||
<!-- Status -->
|
||||
<div v-if="mesh.deadmanStatus" class="mesh-deadman-status">
|
||||
<div class="mesh-deadman-indicator" :class="mesh.deadmanStatus.triggered ? 'triggered' : mesh.deadmanStatus.dead_man_enabled ? 'armed' : 'disabled'">
|
||||
{{ mesh.deadmanStatus.triggered ? 'TRIGGERED' : mesh.deadmanStatus.dead_man_enabled ? 'ARMED' : 'DISABLED' }}
|
||||
</div>
|
||||
<div v-if="mesh.deadmanStatus.dead_man_enabled && !mesh.deadmanStatus.triggered" class="mesh-deadman-timer">
|
||||
{{ formatTimeRemaining(mesh.deadmanStatus.time_remaining_secs) }}
|
||||
</div>
|
||||
<div v-if="deadmanCustomMsg || mesh.deadmanStatus.dead_man_enabled" class="mesh-deadman-message">
|
||||
{{ deadmanCustomMsg || 'Dead man\'s switch triggered — operator unresponsive' }}
|
||||
</div>
|
||||
<button v-if="mesh.deadmanStatus.dead_man_enabled" class="glass-button mesh-deadman-checkin-btn" @click="handleDeadmanCheckin">
|
||||
I'm OK — Check In
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Configuration -->
|
||||
<div class="mesh-deadman-config">
|
||||
<button
|
||||
@click="deadmanEnabled = !deadmanEnabled; handleDeadmanToggle()"
|
||||
class="w-full flex items-center gap-4 p-4 rounded-xl border transition-all text-left mb-3"
|
||||
:class="deadmanEnabled
|
||||
? 'bg-white/10 border-orange-500/40'
|
||||
: 'bg-black/20 border-white/10 hover:border-white/20'"
|
||||
>
|
||||
<svg class="w-5 h-5 shrink-0" :class="deadmanEnabled ? 'text-orange-400' : 'text-white/40'" 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-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium" :class="deadmanEnabled ? 'text-white/95' : 'text-white/70'">{{ deadmanEnabled ? 'Dead Man\'s Switch Active' : 'Enable Dead Man\'s Switch' }}</p>
|
||||
<p class="text-xs text-white/50 mt-0.5">Auto-alerts your contacts if you don't check in</p>
|
||||
</div>
|
||||
<ToggleSwitch :model-value="deadmanEnabled" @click.stop @update:model-value="deadmanEnabled = $event; handleDeadmanToggle()" />
|
||||
</button>
|
||||
|
||||
<template v-if="deadmanEnabled">
|
||||
<div class="mesh-deadman-field">
|
||||
<label class="mesh-bitcoin-label">Trigger Interval</label>
|
||||
<select v-model="deadmanInterval" class="mesh-bitcoin-input mesh-bitcoin-input-sm">
|
||||
<option value="3600">1 hour</option>
|
||||
<option value="21600">6 hours</option>
|
||||
<option value="43200">12 hours</option>
|
||||
<option value="86400">24 hours</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mesh-deadman-field">
|
||||
<label class="mesh-bitcoin-label">Alert Message</label>
|
||||
<input v-model="deadmanCustomMsg" class="mesh-bitcoin-input" placeholder="Dead man's switch triggered — operator unresponsive" />
|
||||
</div>
|
||||
|
||||
<div class="mesh-deadman-info">
|
||||
<span v-if="mesh.deadmanStatus?.has_gps" class="mesh-deadman-info-item">GPS: included</span>
|
||||
<span class="mesh-deadman-info-item">Contacts: {{ mesh.deadmanStatus?.emergency_contacts ?? 0 }}</span>
|
||||
</div>
|
||||
|
||||
<button class="glass-button" :disabled="deadmanConfiguring" @click="handleDeadmanConfigure">
|
||||
{{ deadmanConfiguring ? 'Saving...' : 'Save Configuration' }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /.mesh-tools-wrapper -->
|
||||
</div>
|
||||
|
||||
<!-- Mobile tools: show under peers list on first view -->
|
||||
<div v-if="showMobileTools" class="mesh-mobile-tools">
|
||||
<div class="mesh-tools-tab-bar">
|
||||
<button class="mesh-tab" :class="{ active: toolsTab === 'bitcoin' }" @click="toolsTab = 'bitcoin'">
|
||||
Off-Grid Bitcoin
|
||||
<span v-if="mesh.latestBlockHeight > 0" class="mesh-tab-badge">{{ mesh.latestBlockHeight }}</span>
|
||||
</button>
|
||||
<button class="mesh-tab" :class="{ active: toolsTab === 'deadman' }" @click="toolsTab = 'deadman'">
|
||||
Dead Man
|
||||
<span v-if="mesh.deadmanStatus?.triggered" class="mesh-tab-badge mesh-tab-badge-alert">!</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="showBitcoinPanel" class="glass-card mesh-bitcoin-panel">
|
||||
<!-- Reuse same content via a shared approach - for now inline -->
|
||||
<h3 class="mesh-panel-title">Off-Grid Bitcoin</h3>
|
||||
<p class="mesh-panel-sub">Relay transactions and receive block headers via mesh radio</p>
|
||||
<div class="mesh-bitcoin-section">
|
||||
<div class="mesh-bitcoin-section-header">
|
||||
<span class="mesh-bitcoin-label">Latest Block</span>
|
||||
<span v-if="mesh.latestBlockHeight > 0" class="mesh-bitcoin-height">#{{ mesh.latestBlockHeight.toLocaleString() }}</span>
|
||||
<span v-else class="mesh-bitcoin-height mesh-muted">No headers yet</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showDeadmanPanel" class="glass-card mesh-deadman-panel">
|
||||
<h3 class="mesh-panel-title">Dead Man's Switch</h3>
|
||||
<p class="mesh-panel-sub">Auto-broadcasts a signed emergency alert if you don't check in</p>
|
||||
<div v-if="mesh.deadmanStatus" class="mesh-deadman-status">
|
||||
<div class="mesh-deadman-indicator" :class="mesh.deadmanStatus.triggered ? 'triggered' : mesh.deadmanStatus.dead_man_enabled ? 'armed' : 'disabled'">
|
||||
{{ mesh.deadmanStatus.triggered ? 'TRIGGERED' : mesh.deadmanStatus.dead_man_enabled ? 'ARMED' : 'DISABLED' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mesh-deadman-config">
|
||||
<button
|
||||
@click="deadmanEnabled = !deadmanEnabled; handleDeadmanToggle()"
|
||||
class="w-full flex items-center gap-4 p-4 rounded-xl border transition-all text-left"
|
||||
:class="deadmanEnabled
|
||||
? 'bg-white/10 border-orange-500/40'
|
||||
: 'bg-black/20 border-white/10 hover:border-white/20'"
|
||||
>
|
||||
<svg class="w-5 h-5 shrink-0" :class="deadmanEnabled ? 'text-orange-400' : 'text-white/40'" 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-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium" :class="deadmanEnabled ? 'text-white/95' : 'text-white/70'">{{ deadmanEnabled ? 'Dead Man\'s Switch Active' : 'Enable Dead Man\'s Switch' }}</p>
|
||||
<p class="text-xs text-white/50 mt-0.5">Auto-alerts your contacts if you don't check in</p>
|
||||
</div>
|
||||
<ToggleSwitch :model-value="deadmanEnabled" @click.stop @update:model-value="deadmanEnabled = $event; handleDeadmanToggle()" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -772,7 +887,7 @@ function truncatePubkey(hex: string | null): string {
|
||||
<style scoped>
|
||||
.mesh-view {
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -873,6 +988,65 @@ function truncatePubkey(hex: string | null): string {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Tools wrapper: holds Off-Grid Bitcoin + Dead Man panels */
|
||||
.mesh-tools-wrapper {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* Tools tab bar: hidden by default (medium desktop uses main tab bar) */
|
||||
.mesh-tools-tab-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ─── Wide desktop: 3-column layout ─── */
|
||||
.mesh-columns-wide {
|
||||
display: grid;
|
||||
grid-template-columns: 340px 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mesh-columns-wide .mesh-left {
|
||||
grid-column: 1;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.mesh-columns-wide .mesh-right {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.mesh-columns-wide .mesh-chat-card {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mesh-columns-wide .mesh-tools-wrapper {
|
||||
grid-column: 3;
|
||||
grid-row: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mesh-columns-wide .mesh-tools-tab-bar {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: rgba(0,0,0,0.3);
|
||||
border-radius: 10px;
|
||||
padding: 3px;
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Hide main tab bar and mobile back button on wide desktop */
|
||||
.mesh-columns-wide .mesh-mobile-back-btn,
|
||||
.mesh-columns-wide .mesh-tab-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ─── Status card ─── */
|
||||
.mesh-status-card { padding: 16px; flex-shrink: 0; }
|
||||
|
||||
@@ -1408,12 +1582,17 @@ function truncatePubkey(hex: string | null): string {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ─── Mobile: keep single column ─── */
|
||||
@media (max-width: 768px) {
|
||||
/* ─── Mobile back button (hidden on desktop) ─── */
|
||||
.mesh-mobile-back-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ─── Mobile: single column with panel switching ─── */
|
||||
@media (max-width: 1279px) {
|
||||
.mesh-view {
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
padding: 0 12px 12px 12px;
|
||||
padding: 0 12px 100px 12px; /* bottom padding clears tab bar */
|
||||
}
|
||||
|
||||
.mesh-columns {
|
||||
@@ -1427,7 +1606,44 @@ function truncatePubkey(hex: string | null): string {
|
||||
}
|
||||
|
||||
.mesh-right {
|
||||
min-height: 400px;
|
||||
min-height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Chat takes available viewport height minus tab bars */
|
||||
.mesh-chat-card {
|
||||
min-height: 60dvh;
|
||||
max-height: 75dvh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Hide tools-wrapper inside mesh-right (shown via mesh-mobile-tools instead) */
|
||||
.mesh-tools-wrapper {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Tools section under peers — fixed height so no jump on tab switch */
|
||||
.mesh-mobile-tools {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mesh-mobile-tools .mesh-tools-tab-bar {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: rgba(0,0,0,0.3);
|
||||
border-radius: 10px;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
/* Fixed-height panel container so switching tabs doesn't resize */
|
||||
.mesh-mobile-tools .mesh-bitcoin-panel,
|
||||
.mesh-mobile-tools .mesh-deadman-panel {
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.mesh-status-grid {
|
||||
@@ -1437,6 +1653,23 @@ function truncatePubkey(hex: string | null): string {
|
||||
.mesh-chat-back {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Hide panel on mobile when toggled */
|
||||
.mobile-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Bitcoin and deadman panels should not flex-grow on mobile */
|
||||
.mesh-bitcoin-panel,
|
||||
.mesh-deadman-panel {
|
||||
flex: none;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mesh-mobile-back-btn:hover {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Session badge ─── */
|
||||
@@ -1571,7 +1804,7 @@ function truncatePubkey(hex: string | null): string {
|
||||
}
|
||||
.mesh-bitcoin-input::placeholder { color: rgba(255,255,255,0.3); }
|
||||
.mesh-bitcoin-input:focus { outline: none; border-color: rgba(251,146,60,0.4); }
|
||||
.mesh-bitcoin-input-sm { max-width: 200px; }
|
||||
.mesh-bitcoin-input-sm { width: 100%; }
|
||||
.mesh-relay-mode {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
Reference in New Issue
Block a user