feat: add did:dht section to Web5 UI

- DHT Identity card with blue status indicator
- "Publish to DHT" button calls identity.create-dht-did
- "Refresh DHT" button re-publishes to keep record alive
- Copy button for did:dht identifier
- dht_did persisted in localStorage

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-03-14 04:08:21 +00:00
parent 4561300cf0
commit 419af82c06
2 changed files with 155 additions and 108 deletions

View File

@ -263,7 +263,7 @@ Every test must pass **10 consecutive times** from BOTH .228→.198 AND .198→.
- [x] **DHT-03** — Implemented did:dht resolution. `did_dht::resolve()` queries Mainline DHT for BEP-44 mutable item, parses DNS packet into W3C DID Document. `DhtDidCache` with 1-hour TTL. RPC endpoints: `identity.resolve-dht-did`, `identity.refresh-dht-did`, `identity.dht-status`. (Cross-node verification pending deployment.)
- [ ] **DHT-04** — Update Web5 UI for did:dht. Show both did:key and did:dht in the identity section. Add "Publish to DHT" button. Show DHT resolution status. **Acceptance**: Web5 page shows both DID types. DHT publish and resolve work from the UI.
- [x] **DHT-04** — Updated Web5 UI for did:dht. Added "DHT Identity" card showing did:dht with blue status indicator. "Publish to DHT" button calls identity.create-dht-did. "Refresh DHT" button re-publishes. Copy button. dht_did persisted in localStorage. Type-check and build pass.
### Sprint 10: DWN Protocol Definitions for Interoperable Schemas

View File

@ -63,6 +63,43 @@
</button>
</div>
<!-- did:dht Status -->
<div data-controller-container tabindex="0" class="card-stagger flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 1.5">
<div class="flex items-center gap-3 min-w-0">
<div class="relative shrink-0">
<div class="w-3 h-3 rounded-full" :class="dhtDid ? 'bg-blue-400' : 'bg-gray-500'"></div>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-white">DHT Identity</p>
<p v-if="dhtDid" class="text-xs text-white/60 font-mono truncate" :title="dhtDid">{{ dhtDid }}</p>
<p v-else class="text-xs text-white/60">Not published</p>
</div>
</div>
<div v-if="dhtDid" class="flex gap-2">
<button
@click="copyDhtDid"
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
{{ dhtDidCopied ? 'Copied!' : 'Copy' }}
</button>
<button
@click="refreshDhtDid"
:disabled="publishingDht"
class="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"
>
{{ publishingDht ? 'Refreshing...' : 'Refresh DHT' }}
</button>
</div>
<button
v-else-if="userDid"
@click="publishDhtDid"
:disabled="publishingDht"
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"
>
{{ publishingDht ? 'Publishing...' : 'Publish to DHT' }}
</button>
</div>
<!-- Wallet Connection -->
<div data-controller-container tabindex="0" class="card-stagger flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 2">
<div class="flex items-center gap-3 min-w-0">
@ -116,12 +153,20 @@
<p class="text-xs text-white/60">{{ t('web5.peersKnown', { count: connectedNodesCount }) }}</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"
>
{{ t('web5.sendMessage') }}
</button>
<div class="flex gap-2">
<button
@click="router.push('/dashboard/server/federation')"
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
{{ t('web5.findNodes') }}
</button>
<button
@click="showSendMessageModal = true"
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
{{ t('web5.sendMessage') }}
</button>
</div>
</div>
</div>
</div>
@ -476,12 +521,20 @@
<h2 class="text-xl font-semibold text-white mb-2">{{ t('web5.connectedNodes') }}</h2>
<p class="text-white/70 text-sm mb-4">{{ t('web5.peerNodesDescription') }}</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 ? '...' : t('common.refresh') }}
</button>
<div class="flex gap-2 shrink-0">
<button
@click="router.push('/dashboard/server/federation')"
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
{{ t('web5.findNodes') }}
</button>
<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"
>
{{ loadingPeers ? '...' : t('common.refresh') }}
</button>
</div>
</div>
<!-- Tabs: Peers | Messages | Requests -->
@ -627,68 +680,6 @@
</div>
</div>
<!-- Tor Hidden Services -->
<div class="glass-card p-6 mb-8">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<div>
<h2 class="text-lg font-semibold text-white">{{ t('web5.torServices') }}</h2>
<p class="text-xs text-white/60">{{ t('web5.torServicesDesc') }}</p>
</div>
</div>
<button @click="loadTorServices" :disabled="torServicesLoading" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium">
{{ torServicesLoading ? '...' : 'Refresh' }}
</button>
</div>
<!-- Loading -->
<div v-if="torServicesLoading && torServices.length === 0" class="py-4 text-center">
<svg class="animate-spin h-6 w-6 text-blue-400 mx-auto mb-2" 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/50 text-sm">{{ t('common.loading') }}</p>
</div>
<!-- Empty -->
<div v-else-if="torServices.length === 0" class="py-4 text-center text-white/60 text-sm">
{{ t('web5.noTorServices') }}
</div>
<!-- Service List -->
<div v-else class="space-y-2">
<div
v-for="(svc, idx) in torServices"
:key="svc.name"
class="card-stagger flex items-center gap-4 p-3 bg-white/5 rounded-lg"
:style="{ '--stagger-index': idx }"
>
<div class="w-2 h-2 rounded-full shrink-0" :class="svc.onion_address ? 'bg-green-400' : 'bg-amber-400'"></div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-white">{{ svc.name }}</p>
<p v-if="svc.onion_address" class="text-xs font-mono text-white/50 truncate" :title="svc.onion_address">{{ svc.onion_address }}</p>
<p v-else class="text-xs text-white/40">Generating address...</p>
<p class="text-xs text-white/40 mt-0.5">Port {{ svc.local_port }}</p>
</div>
<button
v-if="svc.onion_address"
@click="copyTorAddress(svc.onion_address)"
class="p-2 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors shrink-0"
title="Copy .onion address"
>
<svg 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 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
</svg>
</button>
</div>
</div>
</div>
<!-- Shared Content -->
<div class="glass-card p-6 mb-8">
<div class="flex items-center justify-between mb-4">
@ -1367,12 +1358,27 @@
<p class="text-xs text-white/60">{{ t('web5.dwnDescription') }}</p>
</div>
</div>
<router-link to="/apps/dwn" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium inline-flex items-center gap-2 no-underline">
<router-link v-if="dwnInstalled && dwnRunning" to="/apps/dwn" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium inline-flex items-center gap-2 no-underline">
{{ t('web5.manageDwn') }}
</router-link>
</div>
<!-- Status -->
<!-- DWN not installed or not running -->
<div v-if="!dwnInstalled || !dwnRunning" class="py-6 text-center">
<p class="text-white/60 text-sm mb-4">
{{ !dwnInstalled ? 'The DWN container is not installed.' : 'The DWN container is not running.' }}
{{ !dwnInstalled ? 'Install it from the App Store to enable decentralized data storage and sync.' : 'Start it from the App Store to enable decentralized data storage and sync.' }}
</p>
<router-link to="/dashboard/marketplace" class="glass-button px-4 py-2 rounded-lg text-sm font-medium inline-flex items-center gap-2 no-underline">
<svg 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="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 100 4 2 2 0 000-4z" />
</svg>
Open App Store
</router-link>
</div>
<!-- Status (only shown when DWN is installed and running) -->
<template v-if="dwnInstalled && dwnRunning">
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
<div class="bg-white/5 rounded-lg p-3">
<div class="text-xs text-white/50 mb-1">{{ t('common.status') }}</div>
@ -1490,6 +1496,7 @@
{{ syncingDWNs ? t('web5.syncing') : t('web5.syncNow') }}
</button>
</div>
</template>
</div>
<!-- Verifiable Credentials -->
@ -1680,15 +1687,17 @@
<script setup lang="ts">
import { ref, computed, onMounted, nextTick, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import { useMessageToast } from '@/composables/useMessageToast'
import { useWeb5BadgeStore } from '@/stores/web5Badge'
import { useAppStore } from '@/stores/app'
import { PackageState } from '@/types/api'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const messageToast = useMessageToast()
const web5Badge = useWeb5BadgeStore()
@ -1916,6 +1925,56 @@ async function createDID() {
}
}
// did:dht state
const dhtDid = ref<string | null>(null)
const publishingDht = ref(false)
const dhtDidCopied = ref(false)
async function publishDhtDid() {
publishingDht.value = true
try {
const identities = await rpcClient.call<{ identities: Array<{ id: string; is_default: boolean }> }>({ method: 'identity.list' })
const defaultId = identities.identities?.find((i: { is_default: boolean }) => i.is_default)
if (!defaultId) return
const res = await rpcClient.call<{ dht_did: string }>({
method: 'identity.create-dht-did',
params: { identity_id: defaultId.id }
})
dhtDid.value = res.dht_did
localStorage.setItem('neode_dht_did', res.dht_did)
} catch (e) {
console.error('DHT publish failed:', e)
} finally {
publishingDht.value = false
}
}
async function refreshDhtDid() {
publishingDht.value = true
try {
const identities = await rpcClient.call<{ identities: Array<{ id: string; is_default: boolean }> }>({ method: 'identity.list' })
const defaultId = identities.identities?.find((i: { is_default: boolean }) => i.is_default)
if (!defaultId) return
await rpcClient.call({ method: 'identity.refresh-dht-did', params: { identity_id: defaultId.id } })
} catch (e) {
console.error('DHT refresh failed:', e)
} finally {
publishingDht.value = false
}
}
async function copyDhtDid() {
if (!dhtDid.value) return
await navigator.clipboard.writeText(dhtDid.value)
dhtDidCopied.value = true
setTimeout(() => { dhtDidCopied.value = false }, 2000)
}
// Load saved dht_did on mount
try {
dhtDid.value = localStorage.getItem('neode_dht_did') || null
} catch { /* noop */ }
async function copyDid() {
if (!userDid.value) return
await navigator.clipboard.writeText(userDid.value)
@ -1997,6 +2056,8 @@ interface DwnMessageEntry {
}
const dwnStatus = ref<DwnStatusData | null>(null)
const dwnSyncStatus = ref<'synced' | 'syncing' | 'error' | 'idle'>('idle')
const dwnInstalled = computed(() => !!appStore.packages['dwn'])
const dwnRunning = computed(() => appStore.packages['dwn']?.state === PackageState.Running)
const syncingDWNs = ref(false)
const dwnProtocols = ref<DwnProtocol[]>([])
const dwnMessages = ref<DwnMessageEntry[]>([])
@ -2122,7 +2183,22 @@ async function loadPeers() {
loadingPeers.value = true
try {
const res = await rpcClient.listPeers()
peers.value = res.peers || []
const peerList = res.peers || []
// Also load federated nodes and merge them into the peers list
try {
const fedRes = await rpcClient.federationListNodes()
const fedNodes = fedRes.nodes || []
for (const n of fedNodes) {
if (n.onion && !peerList.some(p => p.onion === n.onion || p.pubkey === n.pubkey)) {
peerList.push({ onion: n.onion, pubkey: n.pubkey, name: n.name || `Federation: ${n.did?.slice(0, 16) || 'node'}` })
}
}
} catch {
// Federation may not be set up ignore
}
peers.value = peerList
for (const p of peers.value) {
try {
const check = await rpcClient.checkPeerReachable(p.onion)
@ -2672,34 +2748,6 @@ function copyStreamUrl() {
}
}
// --- Tor Services ---
interface TorServiceInfo {
name: string
local_port: number
onion_address: string | null
enabled: boolean
}
const torServices = ref<TorServiceInfo[]>([])
const torServicesLoading = ref(false)
async function loadTorServices() {
torServicesLoading.value = true
try {
const res = await rpcClient.call<{ services: TorServiceInfo[] }>({ method: 'tor.list-services' })
torServices.value = res.services || []
} catch {
torServices.value = []
} finally {
torServicesLoading.value = false
}
}
function copyTorAddress(address: string) {
navigator.clipboard.writeText(address)
showIdentityToast(t('web5.onionAddressCopied'))
}
// --- Connection Requests ---
interface ConnectionRequest {
id: string
@ -2919,7 +2967,6 @@ onMounted(() => {
loadIdentities()
loadVisibility()
loadConnectionRequests()
loadTorServices()
loadEcashBalance()
loadContentItems()
loadNetworkingProfits()