Files
archy/neode-ui/src/views/federation/NodeDetailModal.vue
T
archipelagoandClaude Opus 5 24ce8b39e8
Demo images / Build & push demo images (push) Successful in 4m22s
feat(security): require the node password to grant Trusted
Promotion to Trusted is a privilege escalation — a Trusted peer can read
node state, be deployed to, and is exempt from the `!= Untrusted` gates
federation/DWN/messaging use. It must therefore cost a fresh proof that
the person at the keyboard is the operator, not merely that a session
cookie exists. Same reasoning as node.rotate-identity and TOTP setup,
both of which already re-verify.

Both entry points are covered:

- `federation.invite` gates on the RESOLVED level, not on an explicit
  request for Trusted: "Link Your Nodes" sends no `trust_level` at all
  and falls through to the Trusted default. The invite is a bearer grant
  of Trusted to whoever redeems it, so minting it IS the escalation.
  Observer invites are untouched.
- `federation.set-trust` gates only when the peer is not already
  Trusted, so the dropdown re-emitting its own value doesn't demand a
  password for a no-op.

Demotion is deliberately NOT gated: making something less privileged
must never be harder than leaving it alone, or the safe action becomes
the inconvenient one.

The backend is the sole authority on what counts as an escalation — it
returns a `PASSWORD_REQUIRED:`-prefixed error and the UI prompts and
retries only on that, so the rule lives in exactly one place and the
frontend never pre-judges. TrustPasswordModal.vue (modelled on
RotateDidModal.vue) serves both flows. NodeDetailModal's select snaps
back to the node's real level on change, since a cancelled or failed
promotion would otherwise leave the dropdown displaying a level the node
never accepted.

The operator path stamps TrustSource::Manual; set_trust_level grew an
`Option<TrustSource>` so automatic adjustments (the discovery-handshake
demotion safety net) pass None and leave the recorded provenance alone
rather than laundering an uninvited-join peer into looking approved.

Follow-up, deliberately out of scope: `federation.join` also reaches
Trusted when redeeming someone else's Trusted invite, with no re-auth.

Tests: 44/44 federation, 79/79 rpc-client, vue-tsc clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 13:07:21 -04:00

201 lines
8.5 KiB
Vue

<template>
<Teleport to="body">
<div v-if="node" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="handleClose">
<div class="glass-card p-6 w-full max-w-lg max-h-[80vh] overflow-y-auto">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-white">Node Details</h2>
<button @click="handleClose" class="text-white/40 hover:text-white/70 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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="space-y-4">
<div class="bg-white/5 rounded-lg p-3">
<p class="text-xs text-white/40 mb-1">DID</p>
<p class="text-sm text-white/80 font-mono break-all">{{ node.did }}</p>
</div>
<div class="bg-white/5 rounded-lg p-3">
<p class="text-xs text-white/40 mb-1">Onion Address</p>
<p v-if="node.trust_level === 'trusted'" class="text-sm text-white/80 font-mono break-all">{{ node.onion }}</p>
<p v-else class="text-sm text-white/30 italic">Not visible to peers</p>
</div>
<div class="bg-white/5 rounded-lg p-3">
<p class="text-xs text-white/40 mb-1">Trust Level</p>
<div class="flex items-center gap-2 mt-1">
<select
:value="node.trust_level"
@change="onTrustChange"
class="bg-black/30 text-white text-sm rounded px-2 py-1 border border-white/10"
>
<option value="trusted">Trusted</option>
<option value="observer">Observer</option>
<option value="untrusted">Blocked</option>
</select>
</div>
<p class="text-xs text-white/40 mt-2">
<span class="text-white/30">Granted via:</span> {{ trustSourceLabel }}
</p>
</div>
<div class="bg-white/5 rounded-lg p-3">
<p class="text-xs text-white/40 mb-1">Added</p>
<p class="text-sm text-white/80">{{ node.added_at }}</p>
</div>
<div v-if="node.trust_level === 'trusted' && node.last_state" class="bg-white/5 rounded-lg p-3">
<p class="text-xs text-white/40 mb-2">Resource Usage</p>
<div class="grid grid-cols-2 gap-2 text-sm text-white/70">
<div>CPU: {{ node.last_state.cpu_usage_percent?.toFixed(1) ?? '--' }}%</div>
<div>Uptime: {{ node.last_state.uptime_secs ? formatUptime(node.last_state.uptime_secs) : '--' }}</div>
<div>RAM: {{ formatBytes(node.last_state.mem_used_bytes) }} / {{ formatBytes(node.last_state.mem_total_bytes) }}</div>
<div>Disk: {{ formatBytes(node.last_state.disk_used_bytes) }} / {{ formatBytes(node.last_state.disk_total_bytes) }}</div>
</div>
</div>
<div v-if="node.last_state?.apps?.length && node.trust_level === 'trusted'" class="bg-white/5 rounded-lg p-3">
<p class="text-xs text-white/40 mb-2">Apps ({{ node.last_state.apps.length }})</p>
<div class="space-y-1">
<div v-for="app in node.last_state.apps" :key="app.id" class="flex items-center justify-between text-sm">
<span class="text-white/80">{{ app.id }}</span>
<span class="text-xs" :class="app.status === 'running' ? 'text-green-400' : 'text-white/40'">{{ app.status }}</span>
</div>
</div>
</div>
<!-- Deploy App (trusted only) -->
<div v-if="node.trust_level === 'trusted'" class="bg-white/5 rounded-lg p-3">
<p class="text-xs text-white/40 mb-2">Deploy App</p>
<div class="flex gap-2">
<input
v-model="deployAppId"
placeholder="App ID (e.g. bitcoin)"
class="flex-1 bg-black/30 text-white text-sm rounded px-2 py-1.5 border border-white/10 focus:border-orange-400/50 focus:outline-none"
/>
<button
@click="handleDeploy"
class="px-3 py-1.5 glass-button rounded text-xs text-white/90 font-medium disabled:opacity-50"
:disabled="deploying || !deployAppId.trim()"
>
{{ deploying ? 'Deploying...' : 'Deploy' }}
</button>
</div>
<p v-if="deployResult" class="text-xs mt-2" :class="deployResult.startsWith('Error') ? 'text-red-400' : 'text-green-400'">{{ deployResult }}</p>
</div>
<!-- DWN Sync -->
<div class="bg-white/5 rounded-lg p-3">
<div class="flex items-center justify-between mb-2">
<p class="text-xs text-white/40">DWN Sync</p>
<div class="flex items-center gap-1.5">
<span class="w-1.5 h-1.5 rounded-full" :class="dwnSyncDotClass"></span>
<span class="text-xs text-white/50">{{ dwnSyncLabel }}</span>
</div>
</div>
<div class="grid grid-cols-2 gap-2 text-sm text-white/70 mb-3">
<div><span class="text-white/30">Messages:</span> {{ dwnMessageCount }}</div>
<div><span class="text-white/30">Last sync:</span> {{ dwnLastSync }}</div>
</div>
<button
@click="emit('dwn-sync')"
class="px-3 py-1.5 glass-button rounded text-xs text-white/90 font-medium disabled:opacity-50"
:disabled="dwnSyncing"
>
{{ dwnSyncing ? 'Syncing...' : 'Sync Now' }}
</button>
</div>
<div v-if="!confirmRemove">
<button
@click="confirmRemove = true"
class="w-full mt-4 px-4 py-2 rounded text-sm glass-button glass-button-danger transition-colors"
>
Remove from Federation
</button>
</div>
<div v-else class="mt-4 p-3 bg-red-400/10 rounded-lg border border-red-400/20">
<p class="text-sm text-red-400 mb-3">Are you sure? This node will be removed from your federation.</p>
<div class="flex gap-3">
<button
@click="confirmRemove = false"
class="flex-1 px-3 py-1.5 glass-button rounded text-sm text-white/70"
>Cancel</button>
<button
@click="emit('remove-node', node!.did)"
class="flex-1 px-3 py-1.5 rounded text-sm glass-button glass-button-danger transition-colors font-medium"
>Confirm Remove</button>
</div>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { FederatedNode } from './types'
import { formatBytes, formatUptime } from './utils'
const props = defineProps<{
node: FederatedNode | null
dwnSyncDotClass: string
dwnSyncLabel: string
dwnMessageCount: string
dwnLastSync: string
dwnSyncing: boolean
deploying: boolean
deployResult: string
}>()
const emit = defineEmits<{
close: []
'change-trust': [did: string, level: string]
'remove-node': [did: string]
'deploy-app': [did: string, appId: string]
'dwn-sync': []
}>()
const confirmRemove = ref(false)
const deployAppId = ref('')
const TRUST_SOURCE_LABELS: Record<string, string> = {
invite: 'An invite you minted',
'uninvited-join': 'Joined without an invite — capped at Observer',
'transitive-merge': 'Advertised by another peer — capped at Observer',
manual: 'You set it here',
}
/** Unknown provenance is stated plainly rather than hidden: a peer recorded
* before this was tracked is precisely the one worth a second look. */
const trustSourceLabel = computed(
() => TRUST_SOURCE_LABELS[props.node?.trust_source ?? ''] ?? 'Unknown — recorded before this was tracked',
)
/** Snap the select back to the node's actual level immediately. Promoting to
* Trusted asks for the node password, and the operator may cancel or get it
* wrong — without this the dropdown would keep displaying a level the node
* never accepted. On success the parent reloads and the prop drives the new
* value back in. */
function onTrustChange(event: Event) {
const select = event.target as HTMLSelectElement
const level = select.value
if (!props.node) return
select.value = props.node.trust_level
emit('change-trust', props.node.did, level)
}
function handleClose() {
confirmRemove.value = false
deployAppId.value = ''
emit('close')
}
function handleDeploy() {
if (props.node && deployAppId.value.trim()) {
emit('deploy-app', props.node.did, deployAppId.value.trim())
deployAppId.value = ''
}
}
</script>