Update Fedimint configuration and enhance onboarding process
- Upgraded Fedimint version to v0.10.0 in docker-compose.yml and manifest.yml, adding support for the built-in Guardian UI. - Modified .gitignore to exclude deploy-config.sh script. - Enhanced onboarding process in AuthManager to persist onboarding state and validate password strength during user setup. - Updated API to handle onboarding completion and password change requests, ensuring a smoother user experience. - Improved configuration management to support Nostr discovery and Tor proxy settings, enhancing node identity features.
This commit is contained in:
@@ -268,6 +268,39 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Access (LAN + Tor) Card -->
|
||||
<div v-if="interfaceAddresses" class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">Access</h3>
|
||||
<div class="space-y-3">
|
||||
<div v-if="interfaceAddresses['lan-address']" class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-green-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-white/80 font-medium">LAN</p>
|
||||
<a
|
||||
:href="lanUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-blue-400 hover:text-blue-300 text-sm break-all"
|
||||
>
|
||||
{{ interfaceAddresses['lan-address'] }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isRealOnionAddress(interfaceAddresses['tor-address'])" class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-white/80 font-medium">Tor</p>
|
||||
<span class="text-amber-300/90 text-sm font-mono break-all">{{ torUrl }}</span>
|
||||
<p class="text-white/50 text-xs mt-1">Requires Tor Browser</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Requirements Card -->
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="text-lg font-bold text-white mb-4">Requirements</h3>
|
||||
@@ -407,17 +440,69 @@ const route = useRoute()
|
||||
const store = useAppStore()
|
||||
|
||||
const appId = computed(() => route.params.id as string)
|
||||
// Check both store.packages and dummyApps
|
||||
|
||||
/** Map route/marketplace app IDs to backend package keys (container names). */
|
||||
const ROUTE_TO_PACKAGE_KEY: Record<string, string> = {
|
||||
mempool: 'mempool-web',
|
||||
'mempool-electrs': 'mempool-electrs',
|
||||
electrs: 'mempool-electrs',
|
||||
btcpay: 'btcpay-server',
|
||||
'btcpay-server': 'btcpay-server',
|
||||
fedimint: 'fedimint',
|
||||
lnd: 'lnd',
|
||||
'lnd-ui': 'lnd',
|
||||
bitcoin: 'bitcoin-knots',
|
||||
'bitcoin-knots': 'bitcoin-knots',
|
||||
}
|
||||
|
||||
function resolvePackageKey(routeId: string): string {
|
||||
return ROUTE_TO_PACKAGE_KEY[routeId] ?? routeId
|
||||
}
|
||||
|
||||
// Check both store.packages and dummyApps; resolve route ID to package key for backend data
|
||||
const pkg = computed(() => {
|
||||
// First check real packages
|
||||
if (store.packages[appId.value]) {
|
||||
return store.packages[appId.value]
|
||||
const routeId = appId.value
|
||||
const packageKey = resolvePackageKey(routeId)
|
||||
// First check real packages (try both route id and resolved key)
|
||||
if (store.packages[packageKey]) {
|
||||
return store.packages[packageKey]
|
||||
}
|
||||
if (store.packages[routeId]) {
|
||||
return store.packages[routeId]
|
||||
}
|
||||
// Fall back to dummy apps
|
||||
if (dummyApps[appId.value]) {
|
||||
return dummyApps[appId.value]
|
||||
if (dummyApps[routeId]) {
|
||||
return dummyApps[routeId]
|
||||
}
|
||||
return undefined
|
||||
return null
|
||||
})
|
||||
|
||||
const interfaceAddresses = computed(() => {
|
||||
const main = pkg.value?.installed?.['interface-addresses']?.main
|
||||
if (!main) return null
|
||||
if (!main['lan-address'] && !isRealOnionAddress(main['tor-address'])) return null
|
||||
return main
|
||||
})
|
||||
|
||||
/** V3 onion addresses are 56+ chars + .onion. Placeholders like "btcpay.onion" are not real. */
|
||||
function isRealOnionAddress(addr: string | undefined): boolean {
|
||||
return !!(addr && addr.endsWith('.onion') && addr.length >= 60 && addr.length <= 70)
|
||||
}
|
||||
|
||||
const lanUrl = computed(() => {
|
||||
const addr = interfaceAddresses.value?.['lan-address']
|
||||
if (!addr) return '#'
|
||||
if (addr.includes('localhost')) {
|
||||
return addr.replace('localhost', window.location.hostname)
|
||||
}
|
||||
return addr
|
||||
})
|
||||
|
||||
/** Tor URL with http:// prefix for copy-paste into Tor Browser */
|
||||
const torUrl = computed(() => {
|
||||
const addr = interfaceAddresses.value?.['tor-address']
|
||||
if (!addr || !isRealOnionAddress(addr)) return ''
|
||||
return addr.startsWith('http') ? addr : `http://${addr}`
|
||||
})
|
||||
|
||||
const uninstallModal = ref({
|
||||
@@ -543,8 +628,8 @@ function launchApp() {
|
||||
prod: 'http://localhost:8080'
|
||||
},
|
||||
'fedimint': {
|
||||
dev: 'http://localhost:8173',
|
||||
prod: 'http://localhost:8173'
|
||||
dev: 'http://localhost:8175',
|
||||
prod: 'http://192.168.1.228:8175'
|
||||
},
|
||||
'morphos-server': {
|
||||
dev: 'http://localhost:8081',
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<div
|
||||
v-for="[id, pkg] in sortedPackageEntries"
|
||||
:key="id"
|
||||
class="glass-card p-6 transition-all hover:-translate-y-1 cursor-pointer relative"
|
||||
class="glass-card p-6 transition-all hover:-translate-y-1 cursor-pointer relative min-w-0 overflow-hidden"
|
||||
@click="goToApp(id as string)"
|
||||
>
|
||||
<!-- Uninstall Icon -->
|
||||
@@ -48,8 +48,8 @@
|
||||
class="w-16 h-16 rounded-lg object-cover bg-white/10"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-white mb-1 truncate">
|
||||
<div class="flex-1 min-w-0 overflow-hidden">
|
||||
<h3 class="text-lg font-semibold text-white mb-1 truncate" :title="pkg.manifest.title">
|
||||
{{ pkg.manifest.title }}
|
||||
</h3>
|
||||
<p class="text-sm text-white/70 mb-2 truncate">
|
||||
|
||||
@@ -44,11 +44,9 @@
|
||||
<aside class="hidden md:flex w-[256px] border-r border-glass-border shadow-glass-sm flex-shrink-0 relative flex-col" style="background: rgba(0, 0, 0, 0.25); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px);">
|
||||
<div class="p-6 flex-1">
|
||||
<div class="flex items-center gap-3 mb-8">
|
||||
<div class="logo-gradient-border flex-shrink-0">
|
||||
<img src="/assets/icon/favico-black.svg" alt="Neode" class="w-14 h-14" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">{{ serverName }}</h2>
|
||||
<AnimatedLogo />
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="text-lg font-semibold text-white truncate">{{ serverName }}</h2>
|
||||
<p class="text-xs text-white/60">v{{ version }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -76,6 +74,11 @@
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Controller indicator - Desktop sidebar -->
|
||||
<div class="px-6 pb-2">
|
||||
<ControllerIndicator />
|
||||
</div>
|
||||
|
||||
<!-- User Section - Desktop Only -->
|
||||
<div class="p-6">
|
||||
<button
|
||||
@@ -114,6 +117,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New message toast (top right, glassmorphic) -->
|
||||
<Teleport to="body">
|
||||
<Transition name="toast">
|
||||
<div
|
||||
v-if="toastMessage.show"
|
||||
@click="messageToast.dismissToastAndOpenMessages"
|
||||
class="fixed top-20 right-4 left-4 z-[100] w-auto max-w-md cursor-pointer rounded-xl p-4 transition-all hover:border-white/30 hover:shadow-2xl md:top-6 md:right-6 md:left-auto md:max-w-md toast-glass"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-orange-500/20">
|
||||
<svg class="h-5 w-5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-white">New message</p>
|
||||
<p class="mt-0.5 text-sm text-white/70 line-clamp-2">{{ toastMessage.text }}</p>
|
||||
<p class="mt-1 text-xs text-orange-400">Click to view</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- Persistent Mobile Tabs for Apps/Marketplace -->
|
||||
<div
|
||||
v-if="showAppsTabs"
|
||||
@@ -204,12 +231,11 @@
|
||||
<div :key="route.path" class="view-wrapper">
|
||||
<div
|
||||
:class="[
|
||||
'px-4 pt-4 md:px-8 md:pt-8 overflow-y-auto h-full',
|
||||
'px-4 pt-4 pb-4 md:px-8 md:pt-8 md:pb-8 overflow-y-auto h-full',
|
||||
needsMobileBackButtonSpace
|
||||
? 'pb-[calc(var(--mobile-tab-bar-height,_72px)+96px)]'
|
||||
? 'pb-[calc(var(--mobile-tab-bar-height,_72px)+96px)] md:pb-8'
|
||||
: undefined
|
||||
]"
|
||||
:style="contentPaddingBottomStyle"
|
||||
>
|
||||
<component :is="Component" class="view-container" />
|
||||
</div>
|
||||
@@ -262,8 +288,13 @@
|
||||
import { computed, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { RouterLink, RouterView, useRouter, useRoute } from 'vue-router'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { useMessageToast } from '@/composables/useMessageToast'
|
||||
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
||||
import ControllerIndicator from '@/components/ControllerIndicator.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const messageToast = useMessageToast()
|
||||
const toastMessage = messageToast.toastMessage
|
||||
const route = useRoute()
|
||||
const store = useAppStore()
|
||||
|
||||
@@ -437,9 +468,6 @@ watch(() => route.path, (newPath) => {
|
||||
|
||||
const needsMobileBackButtonSpace = computed(() => isDetailRoute(route.path))
|
||||
|
||||
const contentPaddingBottomStyle = computed(() =>
|
||||
(typeof window !== 'undefined' && window.innerWidth >= 768) ? { paddingBottom: '0' } : undefined
|
||||
)
|
||||
|
||||
// Show persistent tabs for Apps/Marketplace on mobile
|
||||
const showAppsTabs = computed(() => {
|
||||
@@ -501,6 +529,7 @@ function updateNetworkTabIndicator() {
|
||||
|
||||
onMounted(() => {
|
||||
updateTabBarHeight()
|
||||
messageToast.startPolling()
|
||||
updateAppsTabIndicator()
|
||||
updateNetworkTabIndicator()
|
||||
|
||||
@@ -513,6 +542,7 @@ onMounted(() => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updateTabBarHeight)
|
||||
messageToast.stopPolling()
|
||||
})
|
||||
|
||||
// Watch route changes to update indicator position
|
||||
|
||||
+16
-16
@@ -18,8 +18,8 @@
|
||||
<!-- Section Overviews -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<!-- My Apps Overview -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||
<div class="flex items-start justify-between mb-4 shrink-0">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white mb-1">My Apps</h2>
|
||||
<p class="text-sm text-white/70">Manage your installed applications</p>
|
||||
@@ -34,7 +34,7 @@
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<div class="grid grid-cols-2 gap-4 mb-4 flex-1 min-h-0">
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<p class="text-xs text-white/60 mb-1">Installed</p>
|
||||
<p class="text-2xl font-bold text-white">{{ appCount }}</p>
|
||||
@@ -45,7 +45,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex gap-2 mt-auto pt-4 shrink-0">
|
||||
<RouterLink
|
||||
to="/dashboard/marketplace"
|
||||
class="flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors"
|
||||
@@ -62,8 +62,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Cloud Overview -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||
<div class="flex items-start justify-between mb-4 shrink-0">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white mb-1">Cloud</h2>
|
||||
<p class="text-sm text-white/70">Cloud services and storage</p>
|
||||
@@ -78,7 +78,7 @@
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<div class="grid grid-cols-2 gap-4 mb-4 flex-1 min-h-0">
|
||||
<div class="p-4 bg-white/5 rounded-lg">
|
||||
<p class="text-xs text-white/60 mb-1">Storage Used</p>
|
||||
<p class="text-2xl font-bold text-white">2.4 GB</p>
|
||||
@@ -89,7 +89,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex gap-2 mt-auto pt-4 shrink-0">
|
||||
<RouterLink
|
||||
to="/dashboard/cloud"
|
||||
class="flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors"
|
||||
@@ -106,8 +106,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Network Overview -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||
<div class="flex items-start justify-between mb-4 shrink-0">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white mb-1">Network</h2>
|
||||
<p class="text-sm text-white/70">Network infrastructure and Web3 services</p>
|
||||
@@ -122,7 +122,7 @@
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 mb-4">
|
||||
<div class="space-y-3 mb-4 flex-1 min-h-0">
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2 h-2 rounded-full bg-green-400"></div>
|
||||
@@ -146,7 +146,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex gap-2 mt-auto pt-4 shrink-0">
|
||||
<RouterLink
|
||||
to="/dashboard/server"
|
||||
class="flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors"
|
||||
@@ -165,8 +165,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Web5 Overview -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||
<div class="flex items-start justify-between mb-4 shrink-0">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white mb-1">Web5</h2>
|
||||
<p class="text-sm text-white/70">Decentralized identity and data protocols</p>
|
||||
@@ -181,7 +181,7 @@
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 mb-4">
|
||||
<div class="space-y-3 mb-4 flex-1 min-h-0">
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2 h-2 rounded-full bg-green-400"></div>
|
||||
@@ -205,7 +205,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex gap-2 mt-auto pt-4 shrink-0">
|
||||
<RouterLink
|
||||
to="/dashboard/web5"
|
||||
class="flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors"
|
||||
|
||||
@@ -91,26 +91,33 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Category Tabs (Desktop only) -->
|
||||
<div class="hidden md:flex mb-6 glass-card p-2 rounded-lg flex-wrap gap-2">
|
||||
<button
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
@click="selectedCategory = category.id"
|
||||
:class="[
|
||||
'px-6 py-2 rounded-lg font-medium transition-all',
|
||||
selectedCategory === category.id
|
||||
? 'bg-white/20 text-white'
|
||||
: 'text-white/60 hover:text-white/80'
|
||||
]"
|
||||
>
|
||||
{{ category.name }}
|
||||
</button>
|
||||
<!-- Category Tabs + Search (Desktop only) -->
|
||||
<div class="hidden md:flex mb-6 glass-card p-2 rounded-lg items-center justify-between gap-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="category in categoriesWithApps"
|
||||
:key="category.id"
|
||||
@click="selectedCategory = category.id"
|
||||
:class="[
|
||||
'px-6 py-2 rounded-lg font-medium transition-all',
|
||||
selectedCategory === category.id
|
||||
? 'bg-white/20 text-white'
|
||||
: 'text-white/60 hover:text-white/80'
|
||||
]"
|
||||
>
|
||||
{{ category.name }}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search apps..."
|
||||
class="flex-shrink-0 w-64 px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="mb-6 pt-0 md:pt-0">
|
||||
<!-- Search Bar (Mobile - placeholder for later) -->
|
||||
<div class="md:hidden mb-6">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
@@ -297,7 +304,7 @@
|
||||
<!-- Category Grid -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
v-for="category in categories"
|
||||
v-for="category in categoriesWithApps"
|
||||
:key="category.id"
|
||||
@click="selectedCategory = category.id; showFilterModal = false"
|
||||
:class="[
|
||||
@@ -521,6 +528,15 @@ const allApps = computed(() => {
|
||||
return [...local, ...community]
|
||||
})
|
||||
|
||||
// Only show categories that have at least one app
|
||||
const categoriesWithApps = computed(() => {
|
||||
const apps = allApps.value
|
||||
return categories.filter(cat => {
|
||||
if (cat.id === 'all') return apps.length > 0
|
||||
return apps.some(app => app.category === cat.id)
|
||||
})
|
||||
})
|
||||
|
||||
// Filtered apps by category and search
|
||||
const filteredApps = computed(() => {
|
||||
let apps = allApps.value
|
||||
@@ -801,11 +817,11 @@ function getCuratedAppList() {
|
||||
{
|
||||
id: 'fedimint',
|
||||
title: 'Fedimint',
|
||||
version: '0.3.0',
|
||||
description: 'Federated Bitcoin mint. Private, scalable Bitcoin through federated guardians.',
|
||||
icon: '/assets/img/icon-fedimint.jpeg',
|
||||
version: '0.10.0',
|
||||
description: 'Federated Bitcoin mint with built-in Guardian UI. Private, scalable Bitcoin through federated guardians.',
|
||||
icon: '/assets/img/app-icons/fedimint.png',
|
||||
author: 'Fedimint',
|
||||
dockerImage: 'docker.io/fedimint/fedimintd:v0.3.0',
|
||||
dockerImage: 'docker.io/fedimint/fedimintd:v0.10.0',
|
||||
manifestUrl: null,
|
||||
repoUrl: 'https://github.com/fedimint/fedimint'
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="flex flex-col items-center gap-6 mb-6">
|
||||
<!-- Error message -->
|
||||
<p v-if="errorMessage" class="text-red-400 text-sm mb-4">{{ errorMessage }}</p>
|
||||
<!-- Generate Button (if no DID yet) -->
|
||||
<button
|
||||
v-if="!generatedDid"
|
||||
@@ -75,7 +77,8 @@
|
||||
<button
|
||||
v-if="generatedDid"
|
||||
@click="proceed"
|
||||
class="path-action-button path-action-button--continue"
|
||||
:disabled="generatedDid.includes('...')"
|
||||
class="path-action-button path-action-button--continue disabled:opacity-50"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
@@ -87,49 +90,40 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const router = useRouter()
|
||||
const generatedDid = ref<string>('')
|
||||
const isGenerating = ref(false)
|
||||
const errorMessage = ref<string>('')
|
||||
|
||||
async function generateDid() {
|
||||
isGenerating.value = true
|
||||
|
||||
// Simulate DID generation (replace with actual implementation)
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
|
||||
// Mock DID generation - in production, this would call the backend
|
||||
const mockDid = `did:key:z6Mk${generateRandomString(44)}`
|
||||
generatedDid.value = mockDid
|
||||
|
||||
// Store in localStorage
|
||||
localStorage.setItem('neode_did', mockDid)
|
||||
|
||||
isGenerating.value = false
|
||||
}
|
||||
errorMessage.value = ''
|
||||
|
||||
function generateRandomString(length: number): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
let result = ''
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
try {
|
||||
const { did, pubkey } = await rpcClient.getNodeDid()
|
||||
generatedDid.value = did
|
||||
localStorage.setItem('neode_did', did)
|
||||
localStorage.setItem('neode_did_state', JSON.stringify({ did, kid: pubkey }))
|
||||
} catch (err) {
|
||||
errorMessage.value = err instanceof Error ? err.message : 'Failed to load node identity'
|
||||
// Fallback: show placeholder if backend unavailable (e.g. mock mode)
|
||||
if (!generatedDid.value) {
|
||||
generatedDid.value = 'did:key:z6Mk... (connect to server)'
|
||||
}
|
||||
} finally {
|
||||
isGenerating.value = false
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function proceed() {
|
||||
// Store DID state and continue to backup
|
||||
if (generatedDid.value) {
|
||||
localStorage.setItem('neode_did_state', JSON.stringify({
|
||||
did: generatedDid.value,
|
||||
kid: 'kid:mock'
|
||||
}))
|
||||
if (generatedDid.value && !generatedDid.value.includes('...')) {
|
||||
router.push('/onboarding/backup')
|
||||
}
|
||||
}
|
||||
|
||||
function skipForNow() {
|
||||
// Skip to backup screen
|
||||
router.push('/onboarding/backup')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { completeOnboarding } from '@/composables/useOnboarding'
|
||||
|
||||
const router = useRouter()
|
||||
const selected = ref<string | null>(null)
|
||||
@@ -96,13 +97,9 @@ function selectOption(option: string) {
|
||||
selected.value = option
|
||||
}
|
||||
|
||||
function proceed() {
|
||||
async function proceed() {
|
||||
if (selected.value) {
|
||||
// Mark onboarding as complete
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
|
||||
// For now, just go to login
|
||||
// In a real app, you'd have different flows for each option
|
||||
await completeOnboarding()
|
||||
router.push('/login')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { completeOnboarding } from '@/composables/useOnboarding'
|
||||
|
||||
const router = useRouter()
|
||||
const verified = ref(false)
|
||||
@@ -91,15 +92,14 @@ const signature = ref('')
|
||||
|
||||
async function signChallenge() {
|
||||
isSigning.value = true
|
||||
|
||||
|
||||
// Simulate signing challenge
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
|
||||
// Mock signature generation
|
||||
|
||||
const mockSignature = generateMockSignature()
|
||||
signature.value = mockSignature
|
||||
verified.value = true
|
||||
|
||||
|
||||
isSigning.value = false
|
||||
}
|
||||
|
||||
@@ -112,16 +112,14 @@ function generateMockSignature(): string {
|
||||
return result
|
||||
}
|
||||
|
||||
function proceed() {
|
||||
// Mark onboarding as complete
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
router.push('/onboarding/done')
|
||||
async function proceed() {
|
||||
await completeOnboarding()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
function skipForNow() {
|
||||
// Mark onboarding as complete
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
router.push('/onboarding/done')
|
||||
async function skipForNow() {
|
||||
await completeOnboarding()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center bg-black/40">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<svg class="animate-spin h-10 w-10 text-white/80" 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>
|
||||
<p class="text-white/60 text-sm">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { isOnboardingComplete } from '@/composables/useOnboarding'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(async () => {
|
||||
const devMode = import.meta.env.VITE_DEV_MODE
|
||||
if (devMode === 'setup' || devMode === 'existing') {
|
||||
router.replace('/login')
|
||||
return
|
||||
}
|
||||
|
||||
const seenOnboarding = await isOnboardingComplete()
|
||||
router.replace(seenOnboarding ? '/login' : '/onboarding/intro')
|
||||
})
|
||||
</script>
|
||||
@@ -8,22 +8,22 @@
|
||||
|
||||
<!-- Quick Actions Container -->
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<!-- Service Status -->
|
||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative">
|
||||
<div class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="relative shrink-0">
|
||||
<div class="w-3 h-3 rounded-full" :class="servicesRunning ? 'bg-green-400' : 'bg-red-400'"></div>
|
||||
<div v-if="servicesRunning" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-white">Services</p>
|
||||
<p class="text-xs text-white/60">{{ servicesRunning ? 'All Running' : 'Some Stopped' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="restartServices"
|
||||
class="px-3 py-1.5 glass-button rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
:disabled="restarting"
|
||||
>
|
||||
{{ restarting ? 'Restarting...' : 'Restart' }}
|
||||
@@ -31,20 +31,20 @@
|
||||
</div>
|
||||
|
||||
<!-- Connectivity Status -->
|
||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative">
|
||||
<div class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="relative shrink-0">
|
||||
<div class="w-3 h-3 rounded-full" :class="connectivityStatus === 'connected' ? 'bg-green-400' : connectivityStatus === 'checking' ? 'bg-yellow-400' : 'bg-red-400'"></div>
|
||||
<div v-if="connectivityStatus === 'connected'" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-white">Connectivity</p>
|
||||
<p class="text-xs text-white/60 capitalize">{{ connectivityStatus }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="checkConnectivity"
|
||||
class="px-3 py-1.5 glass-button rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
:disabled="checkingConnectivity"
|
||||
>
|
||||
{{ checkingConnectivity ? 'Checking...' : 'Check' }}
|
||||
@@ -52,19 +52,19 @@
|
||||
</div>
|
||||
|
||||
<!-- Auto-Sync Toggle -->
|
||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<svg class="w-5 h-5 text-white/60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
||||
</svg>
|
||||
<div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-white">Auto-Sync</p>
|
||||
<p class="text-xs text-white/60">{{ autoSyncEnabled ? 'Enabled' : 'Disabled' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="toggleAutoSync"
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors self-start"
|
||||
:class="autoSyncEnabled ? 'bg-green-500' : 'bg-white/20'"
|
||||
>
|
||||
<span
|
||||
@@ -75,19 +75,19 @@
|
||||
</div>
|
||||
|
||||
<!-- Logs & Diagnostics -->
|
||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<svg class="w-5 h-5 text-white/60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-white">Logs</p>
|
||||
<p class="text-xs text-white/60">{{ logCount > 0 ? `${logCount} new` : 'No new logs' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="viewLogs"
|
||||
class="px-3 py-1.5 glass-button rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
@@ -98,8 +98,8 @@
|
||||
<!-- Overview Cards -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<!-- Local Network Card -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||
<div class="flex items-start gap-4 mb-4 shrink-0">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
|
||||
@@ -111,7 +111,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-3 flex-1 min-h-0">
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -153,14 +153,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="mt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors">
|
||||
<button class="mt-auto pt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors shrink-0">
|
||||
Manage Local Network
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Web3 Card -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||
<div class="flex items-start gap-4 mb-4 shrink-0">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
@@ -172,7 +172,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-3 flex-1 min-h-0">
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -214,7 +214,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="mt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors">
|
||||
<button class="mt-auto pt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors shrink-0">
|
||||
Manage Web3 Services
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2 drop-shadow-[0_2px_8px_rgba(0,0,0,0.6)]">Settings</h1>
|
||||
<p class="text-white/80">Configure your Archipelago experience</p>
|
||||
<div class="mb-8 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2 drop-shadow-[0_2px_8px_rgba(0,0,0,0.6)]">Settings</h1>
|
||||
<p class="text-white/80">Configure your Archipelago experience</p>
|
||||
</div>
|
||||
<!-- Controller indicator - Mobile only (desktop shows in sidebar) -->
|
||||
<div class="md:hidden">
|
||||
<ControllerIndicator />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account Section -->
|
||||
@@ -43,8 +49,131 @@
|
||||
</div>
|
||||
<p class="text-base font-medium text-white/90">Currently logged in</p>
|
||||
</div>
|
||||
|
||||
<!-- Identity Card: DID + Tor Address (onion below DID, with copy) -->
|
||||
<div v-if="userDid || serverTorAddress" class="bg-black/20 rounded-xl px-5 py-4 border border-white/10 md:col-span-2 space-y-4">
|
||||
<!-- DID -->
|
||||
<div v-if="userDid">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<svg class="w-5 h-5 text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">Your DID</p>
|
||||
</div>
|
||||
<p class="text-sm font-mono text-white/90 break-all" :title="userDid">{{ userDid }}</p>
|
||||
<p class="text-xs text-white/50 mt-1">Decentralized identifier for passwordless auth</p>
|
||||
</div>
|
||||
|
||||
<!-- Tor / Onion Address (below DID, with copy button) -->
|
||||
<div v-if="serverTorAddress" :class="userDid ? 'pt-4 border-t border-white/10' : ''">
|
||||
<div class="flex items-center justify-between gap-2 mb-2">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<svg class="w-5 h-5 text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">Node .onion Address</p>
|
||||
</div>
|
||||
<button
|
||||
@click="copyOnionAddress"
|
||||
class="shrink-0 px-3 py-1.5 rounded-lg glass-button glass-button-sm text-xs font-medium text-white/90 hover:text-white transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<svg v-if="!copiedOnion" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span v-else class="text-green-400 text-xs">Copied</span>
|
||||
<span v-if="!copiedOnion">Copy</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm font-mono text-amber-400/90 break-all" :title="serverTorAddress">{{ serverTorAddress }}</p>
|
||||
<p class="text-xs text-white/50 mt-1">Onion address for node interface and peer discovery over Tor</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change Password -->
|
||||
<div class="mb-6">
|
||||
<button
|
||||
@click="showChangePasswordModal = true"
|
||||
class="w-full flex items-center justify-center gap-2 mb-4 px-4 py-2 rounded-lg border border-orange-500/50 text-orange-400 font-medium hover:bg-orange-500/10 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
<span>Change Password</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Change Password Modal -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showChangePasswordModal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
||||
@click.self="showChangePasswordModal = false"
|
||||
>
|
||||
<div class="glass-card p-6 max-w-md w-full">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Change Password</h3>
|
||||
<p class="text-white/70 text-sm mb-4">Updates both web login and SSH access. Use a strong password (12+ chars, upper, lower, digit, special).</p>
|
||||
<form @submit.prevent="handleChangePassword" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">Current Password</label>
|
||||
<input
|
||||
v-model="changePasswordForm.currentPassword"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
|
||||
placeholder="Enter current password"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">New Password</label>
|
||||
<input
|
||||
v-model="changePasswordForm.newPassword"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
|
||||
placeholder="12+ chars, upper, lower, digit, special"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">Confirm New Password</label>
|
||||
<input
|
||||
v-model="changePasswordForm.confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
|
||||
placeholder="Re-enter new password"
|
||||
/>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-white/80">
|
||||
<input v-model="changePasswordForm.alsoChangeSsh" type="checkbox" class="rounded border-white/30" />
|
||||
Also update SSH password (recommended)
|
||||
</label>
|
||||
<p v-if="changePasswordError" class="text-sm text-red-400">{{ changePasswordError }}</p>
|
||||
<p v-if="changePasswordSuccess" class="text-sm text-green-400">{{ changePasswordSuccess }}</p>
|
||||
<div class="flex gap-3 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="changingPassword"
|
||||
class="flex-1 px-4 py-2 rounded-lg bg-orange-500 text-white font-medium hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{{ changingPassword ? 'Updating...' : 'Update Password' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="closeChangePasswordModal"
|
||||
class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Logout Button -->
|
||||
<button
|
||||
@click="handleLogout"
|
||||
@@ -72,15 +201,122 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import ControllerIndicator from '@/components/ControllerIndicator.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useAppStore()
|
||||
|
||||
const serverName = computed(() => store.serverName)
|
||||
const version = computed(() => store.serverInfo?.version || '0.0.0')
|
||||
const serverTorAddressFromStore = computed(() => store.serverInfo?.['tor-address'] || null)
|
||||
const torAddressFromRpc = ref<string | null>(null)
|
||||
const serverTorAddress = computed(() => serverTorAddressFromStore.value || torAddressFromRpc.value)
|
||||
const userDid = computed(() => {
|
||||
try {
|
||||
return localStorage.getItem('neode_did') || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const copiedOnion = ref(false)
|
||||
const showChangePasswordModal = ref(false)
|
||||
const changingPassword = ref(false)
|
||||
const changePasswordError = ref('')
|
||||
const changePasswordSuccess = ref('')
|
||||
const changePasswordForm = ref({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
alsoChangeSsh: true,
|
||||
})
|
||||
|
||||
function validatePasswordStrength(pw: string): string | null {
|
||||
if (pw.length < 12) return 'Password must be at least 12 characters'
|
||||
if (!/[A-Z]/.test(pw)) return 'Password must contain at least one uppercase letter'
|
||||
if (!/[a-z]/.test(pw)) return 'Password must contain at least one lowercase letter'
|
||||
if (!/\d/.test(pw)) return 'Password must contain at least one digit'
|
||||
if (!/[^A-Za-z0-9]/.test(pw)) return 'Password must contain at least one special character (!@#$%^&* etc.)'
|
||||
return null
|
||||
}
|
||||
|
||||
async function handleChangePassword() {
|
||||
changePasswordError.value = ''
|
||||
changePasswordSuccess.value = ''
|
||||
const { currentPassword, newPassword, confirmPassword, alsoChangeSsh } = changePasswordForm.value
|
||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||
changePasswordError.value = 'All fields are required'
|
||||
return
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
changePasswordError.value = 'New passwords do not match'
|
||||
return
|
||||
}
|
||||
const strengthError = validatePasswordStrength(newPassword)
|
||||
if (strengthError) {
|
||||
changePasswordError.value = strengthError
|
||||
return
|
||||
}
|
||||
changingPassword.value = true
|
||||
try {
|
||||
await rpcClient.changePassword({
|
||||
currentPassword,
|
||||
newPassword,
|
||||
alsoChangeSsh,
|
||||
})
|
||||
changePasswordSuccess.value = 'Password updated successfully. Use the new password for login and SSH.'
|
||||
changePasswordForm.value = { currentPassword: '', newPassword: '', confirmPassword: '', alsoChangeSsh: true }
|
||||
setTimeout(() => {
|
||||
closeChangePasswordModal()
|
||||
}, 2000)
|
||||
} catch (e) {
|
||||
changePasswordError.value = e instanceof Error ? e.message : 'Failed to change password'
|
||||
} finally {
|
||||
changingPassword.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyOnionAddress() {
|
||||
const addr = serverTorAddress.value
|
||||
if (!addr) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(addr)
|
||||
copiedOnion.value = true
|
||||
setTimeout(() => { copiedOnion.value = false }, 2000)
|
||||
} catch {
|
||||
// Fallback for older browsers
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = addr
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
copiedOnion.value = true
|
||||
setTimeout(() => { copiedOnion.value = false }, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
function closeChangePasswordModal() {
|
||||
showChangePasswordModal.value = false
|
||||
changePasswordError.value = ''
|
||||
changePasswordSuccess.value = ''
|
||||
changePasswordForm.value = { currentPassword: '', newPassword: '', confirmPassword: '', alsoChangeSsh: true }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!serverTorAddressFromStore.value) {
|
||||
try {
|
||||
const res = await rpcClient.getTorAddress()
|
||||
torAddressFromRpc.value = res.tor_address ?? null
|
||||
} catch {
|
||||
// Ignore - tor address may not be available yet
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function handleLogout() {
|
||||
await store.logout()
|
||||
|
||||
+354
-36
@@ -8,14 +8,14 @@
|
||||
|
||||
<!-- Quick Actions Container -->
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
|
||||
<!-- Networking Profits -->
|
||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative">
|
||||
<div class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="relative shrink-0">
|
||||
<span class="text-2xl text-orange-500 font-bold">₿</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-white">Networking Profits</p>
|
||||
<p class="text-xs text-orange-500 font-medium">₿0.024</p>
|
||||
</div>
|
||||
@@ -23,40 +23,41 @@
|
||||
</div>
|
||||
|
||||
<!-- DID Status -->
|
||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative">
|
||||
<div class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="relative shrink-0">
|
||||
<div class="w-3 h-3 rounded-full" :class="didStatus === 'active' ? 'bg-green-400' : 'bg-yellow-400'"></div>
|
||||
<div v-if="didStatus === 'active'" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-white">DID Status</p>
|
||||
<p class="text-xs text-white/60 capitalize">{{ didStatus }}</p>
|
||||
<p v-if="userDid" class="text-xs text-white/60 font-mono truncate" :title="userDid">{{ userDid }}</p>
|
||||
<p v-else class="text-xs text-white/60 capitalize">{{ didStatus }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="manageDIDs"
|
||||
class="px-3 py-1.5 glass-button rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
Manage
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Wallet Connection -->
|
||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative">
|
||||
<div class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="relative shrink-0">
|
||||
<div class="w-3 h-3 rounded-full" :class="walletConnected ? 'bg-green-400' : 'bg-red-400'"></div>
|
||||
<div v-if="walletConnected" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-white">Wallet</p>
|
||||
<p class="text-xs text-white/60">{{ walletConnected ? 'Connected' : 'Disconnected' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="connectWallet"
|
||||
class="px-3 py-1.5 glass-button rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
:disabled="connectingWallet"
|
||||
>
|
||||
{{ connectingWallet ? 'Connecting...' : walletConnected ? 'Disconnect' : 'Connect' }}
|
||||
@@ -64,32 +65,102 @@
|
||||
</div>
|
||||
|
||||
<!-- Nostr Relay Status -->
|
||||
<div class="flex items-center justify-between p-4 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative">
|
||||
<div class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="relative shrink-0">
|
||||
<div class="w-3 h-3 rounded-full" :class="nostrRelaysConnected > 0 ? 'bg-green-400' : 'bg-red-400'"></div>
|
||||
<div v-if="nostrRelaysConnected > 0" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-white">Nostr Relays</p>
|
||||
<p class="text-xs text-white/60">{{ nostrRelaysConnected }} connected</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="manageRelays"
|
||||
class="px-3 py-1.5 glass-button rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
Manage
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Connected Nodes -->
|
||||
<div class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="relative shrink-0">
|
||||
<div class="w-3 h-3 rounded-full" :class="connectedNodesCount > 0 ? 'bg-green-400' : 'bg-amber-400'"></div>
|
||||
<div v-if="connectedNodesCount > 0" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-pulse opacity-75"></div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-white">Connected Nodes</p>
|
||||
<p class="text-xs text-white/60">{{ connectedNodesCount }} peer{{ connectedNodesCount !== 1 ? 's' : '' }} known</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="showSendMessageModal = true"
|
||||
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
Send Message
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Send Message Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showSendMessageModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" @click.self="showSendMessageModal = false">
|
||||
<div class="glass-card p-6 max-w-md w-full max-h-[90vh] overflow-y-auto">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Send Message (over Tor)</h3>
|
||||
<p class="text-white/70 text-sm mb-4">Messages are sent over the Tor network to the selected peer.</p>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">To</label>
|
||||
<select
|
||||
v-model="sendMessageTo"
|
||||
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
|
||||
>
|
||||
<option value="">Select a peer...</option>
|
||||
<option v-for="p in peers" :key="p.pubkey" :value="p.onion">
|
||||
{{ p.name || p.onion || p.pubkey.slice(0, 12) + '...' }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-2">Message</label>
|
||||
<textarea
|
||||
v-model="sendMessageText"
|
||||
rows="3"
|
||||
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
|
||||
placeholder="Type your message..."
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button
|
||||
@click="sendMessage"
|
||||
:disabled="!sendMessageTo || !sendMessageText.trim() || sendingMessage"
|
||||
class="flex-1 px-4 py-2 rounded-lg bg-orange-500 text-white font-medium hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{{ sendingMessage ? 'Sending...' : 'Send' }}
|
||||
</button>
|
||||
<button
|
||||
@click="showSendMessageModal = false"
|
||||
class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="sendMessageError" class="mt-3 text-sm text-red-400">{{ sendMessageError }}</p>
|
||||
<p v-if="sendMessageSuccess" class="mt-3 text-sm text-green-400">{{ sendMessageSuccess }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Core Services Overview Cards -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
||||
<!-- Bitcoin Domain Name Portfolio -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||
<div class="flex items-start gap-4 mb-4 shrink-0">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||
@@ -101,7 +172,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-3 flex-1 min-h-0">
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -133,14 +204,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="mt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors">
|
||||
<button class="mt-auto pt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors shrink-0">
|
||||
Manage Domains
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Web5 Wallet -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||
<div class="flex items-start gap-4 mb-4 shrink-0">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
@@ -152,7 +223,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-3 flex-1 min-h-0">
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-lg text-orange-500 font-bold">₿</span>
|
||||
@@ -182,14 +253,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="mt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors">
|
||||
<button class="mt-auto pt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors shrink-0">
|
||||
Open Wallet
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Nostr Relays -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||
<div class="flex items-start gap-4 mb-4 shrink-0">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
|
||||
@@ -201,7 +272,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-3 flex-1 min-h-0">
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -233,10 +304,116 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="mt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors">
|
||||
<button class="mt-auto pt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors shrink-0">
|
||||
Manage Relays
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Connected Nodes (P2P over Tor) -->
|
||||
<div ref="nodesContainerRef" class="glass-card p-6 lg:col-span-3 scroll-mt-24">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="text-xl font-semibold text-white mb-2">Connected Nodes</h2>
|
||||
<p class="text-white/70 text-sm mb-4">Peer nodes discovered via Nostr. Messages sent over Tor.</p>
|
||||
</div>
|
||||
<button
|
||||
@click="loadPeers"
|
||||
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors shrink-0"
|
||||
>
|
||||
{{ loadingPeers ? '...' : 'Refresh' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs: Peers | Messages -->
|
||||
<div class="flex gap-1 mb-4 border-b border-white/10">
|
||||
<button
|
||||
@click="nodesContainerTab = 'peers'"
|
||||
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
|
||||
:class="nodesContainerTab === 'peers' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
|
||||
>
|
||||
Peers
|
||||
<span v-if="peers.length > 0" class="ml-1.5 text-xs text-white/50">({{ peers.length }})</span>
|
||||
</button>
|
||||
<button
|
||||
@click="switchToMessagesTab"
|
||||
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors flex items-center gap-1.5"
|
||||
:class="nodesContainerTab === 'messages' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
|
||||
>
|
||||
Messages
|
||||
<span v-if="receivedMessages.length > 0" class="ml-1.5 text-xs" :class="unreadCount > 0 ? 'text-orange-400' : 'text-white/50'">({{ receivedMessages.length }})</span>
|
||||
<span v-if="unreadCount > 0" class="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Peers tab -->
|
||||
<div v-show="nodesContainerTab === 'peers'" class="space-y-2 max-h-48 overflow-y-auto">
|
||||
<div v-if="peers.length === 0" class="p-4 text-center text-white/60 text-sm">
|
||||
No peers yet. Add a peer manually or use Discover to find nodes on Nostr.
|
||||
</div>
|
||||
<div
|
||||
v-for="p in peers"
|
||||
:key="p.pubkey"
|
||||
class="flex items-center justify-between p-3 bg-white/5 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="w-2 h-2 rounded-full shrink-0" :class="peerReachable[p.onion] ? 'bg-green-400' : 'bg-amber-400'"></div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-mono text-white/90 truncate">{{ p.name || p.onion || p.pubkey.slice(0, 16) + '...' }}</p>
|
||||
<p class="text-xs text-white/50 truncate">{{ p.onion }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="showSendMessageModal = true; sendMessageTo = p.onion"
|
||||
class="px-2 py-1 text-xs rounded bg-orange-500/20 text-orange-400 hover:bg-orange-500/30 transition-colors shrink-0"
|
||||
>
|
||||
Message
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages tab -->
|
||||
<div v-show="nodesContainerTab === 'messages'" class="space-y-2 max-h-64 overflow-y-auto">
|
||||
<div v-if="loadingMessages" class="p-4 text-center text-white/60 text-sm">
|
||||
Loading messages...
|
||||
</div>
|
||||
<div v-else-if="receivedMessages.length === 0" class="p-4 text-center text-white/60 text-sm">
|
||||
No messages yet. Messages from peers will appear here.
|
||||
</div>
|
||||
<div
|
||||
v-for="(m, idx) in receivedMessages"
|
||||
:key="idx"
|
||||
class="p-3 bg-white/5 rounded-lg border-l-2 border-orange-500/50"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2 mb-1">
|
||||
<p class="text-xs font-mono text-white/60 truncate" :title="m.from_pubkey">{{ m.from_pubkey.slice(0, 16) }}...</p>
|
||||
<span class="text-xs text-white/40 shrink-0">{{ formatMessageTime(m.timestamp) }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-white/90 break-words">{{ m.message }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="nodesContainerTab === 'peers'"
|
||||
@click="discoverAndAddPeers"
|
||||
:disabled="discovering"
|
||||
class="mt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{{ discovering ? 'Discovering...' : 'Discover Nodes on Nostr' }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click="loadReceivedMessages"
|
||||
:disabled="loadingMessages"
|
||||
class="mt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{{ loadingMessages ? 'Loading...' : 'Refresh Messages' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Protocol Overview Cards -->
|
||||
@@ -449,10 +626,26 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useMessageToast } from '@/composables/useMessageToast'
|
||||
|
||||
// DID Status: 'active' | 'inactive' | 'pending'
|
||||
const didStatus = ref<'active' | 'inactive' | 'pending'>('active')
|
||||
const route = useRoute()
|
||||
const messageToast = useMessageToast()
|
||||
|
||||
const userDid = computed(() => {
|
||||
try {
|
||||
return localStorage.getItem('neode_did') || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
// DID Status: 'active' when user has DID, else 'inactive'
|
||||
const didStatus = computed<'active' | 'inactive' | 'pending'>(() =>
|
||||
userDid.value ? 'active' : 'inactive'
|
||||
)
|
||||
|
||||
// DWN Sync Status: 'synced' | 'syncing' | 'error'
|
||||
const dwnSyncStatus = ref<'synced' | 'syncing' | 'error'>('synced')
|
||||
@@ -465,6 +658,130 @@ const connectingWallet = ref(false)
|
||||
// Nostr Relays
|
||||
const nostrRelaysConnected = ref(8)
|
||||
|
||||
// Connected Nodes (peers)
|
||||
const peers = ref<Array<{ onion: string; pubkey: string; name?: string }>>([])
|
||||
const loadingPeers = ref(false)
|
||||
const peerReachable = ref<Record<string, boolean>>({})
|
||||
const connectedNodesCount = computed(() => peers.value.length)
|
||||
|
||||
// Send Message modal
|
||||
const showSendMessageModal = ref(false)
|
||||
const sendMessageTo = ref('')
|
||||
const sendMessageText = ref('')
|
||||
const sendingMessage = ref(false)
|
||||
const sendMessageError = ref('')
|
||||
const sendMessageSuccess = ref('')
|
||||
const discovering = ref(false)
|
||||
|
||||
// Connected Nodes container: tabs + messages (uses shared composable for polling from Dashboard)
|
||||
const nodesContainerRef = ref<HTMLElement | null>(null)
|
||||
const nodesContainerTab = ref<'peers' | 'messages'>('peers')
|
||||
const { receivedMessages, loadingMessages, unreadCount, loadReceivedMessages, markAsRead } = messageToast
|
||||
|
||||
function formatMessageTime(ts: string): string {
|
||||
try {
|
||||
const d = new Date(ts)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - d.getTime()
|
||||
if (diff < 60000) return 'Just now'
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`
|
||||
return d.toLocaleDateString()
|
||||
} catch {
|
||||
return ts
|
||||
}
|
||||
}
|
||||
|
||||
function switchToMessagesTab() {
|
||||
nodesContainerTab.value = 'messages'
|
||||
markAsRead()
|
||||
}
|
||||
|
||||
async function loadPeers() {
|
||||
loadingPeers.value = true
|
||||
try {
|
||||
const res = await rpcClient.listPeers()
|
||||
peers.value = res.peers || []
|
||||
for (const p of peers.value) {
|
||||
try {
|
||||
const check = await rpcClient.checkPeerReachable(p.onion)
|
||||
peerReachable.value[p.onion] = check.reachable
|
||||
} catch {
|
||||
peerReachable.value[p.onion] = false
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load peers:', e)
|
||||
} finally {
|
||||
loadingPeers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
if (!sendMessageTo.value || !sendMessageText.value.trim()) return
|
||||
sendingMessage.value = true
|
||||
sendMessageError.value = ''
|
||||
sendMessageSuccess.value = ''
|
||||
try {
|
||||
await rpcClient.sendMessageToPeer(sendMessageTo.value, sendMessageText.value.trim())
|
||||
sendMessageSuccess.value = 'Message sent over Tor!'
|
||||
sendMessageText.value = ''
|
||||
setTimeout(() => {
|
||||
showSendMessageModal.value = false
|
||||
sendMessageSuccess.value = ''
|
||||
}, 1500)
|
||||
} catch (e) {
|
||||
sendMessageError.value = e instanceof Error ? e.message : 'Failed to send'
|
||||
} finally {
|
||||
sendingMessage.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverAndAddPeers() {
|
||||
discovering.value = true
|
||||
try {
|
||||
const res = await rpcClient.discoverNodes()
|
||||
const nodes = res.nodes || []
|
||||
for (const n of nodes) {
|
||||
if (n.onion && n.pubkey) {
|
||||
try {
|
||||
await rpcClient.addPeer({ onion: n.onion, pubkey: n.pubkey })
|
||||
} catch {
|
||||
// may already exist
|
||||
}
|
||||
}
|
||||
}
|
||||
await loadPeers()
|
||||
} catch (e) {
|
||||
console.error('Discover failed:', e)
|
||||
} finally {
|
||||
discovering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadPeers()
|
||||
loadReceivedMessages()
|
||||
// Open Messages tab when navigated via toast (e.g. ?tab=messages)
|
||||
if (route.query.tab === 'messages') {
|
||||
nodesContainerTab.value = 'messages'
|
||||
markAsRead()
|
||||
nextTick(() => {
|
||||
nodesContainerRef.value?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => route.query.tab, (tab) => {
|
||||
if (tab === 'messages') {
|
||||
nodesContainerTab.value = 'messages'
|
||||
markAsRead()
|
||||
nextTick(() => {
|
||||
nodesContainerRef.value?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function manageDIDs() {
|
||||
// TODO: Navigate to DID management or open modal
|
||||
console.log('Managing DIDs...')
|
||||
@@ -506,3 +823,4 @@ function manageRelays() {
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user