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:
Dorian
2026-03-19 12:44:31 +00:00
parent d1b48388fb
commit 1a74a930f7
77 changed files with 2485 additions and 966 deletions

View File

@@ -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">&#x1F4E1;</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;