feat: NostrVPN as native system service, Claude API key input, fix duplicate password
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Failing after 1m0s

- Add NostrVPN as a native systemd service (extracted from container)
- Add VPN status detection for nostr-vpn in backend vpn.rs
- ISO build extracts nvpn binary from container image
- First-boot auto-configures NostrVPN with node's Nostr identity
- Change Claude Auth from login iframe to API key input field
- Remove duplicate ChangePasswordSection from Settings.vue
- FIPS and Routstr remain as installable container apps

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-04-07 14:40:33 +01:00
parent dc6496e693
commit e97fee2d7e
7 changed files with 348 additions and 85 deletions

View File

@@ -1,13 +1,11 @@
<script setup lang="ts">
import AccountSection from '@/views/settings/AccountSection.vue'
import ChangePasswordSection from '@/views/settings/ChangePasswordSection.vue'
import SystemSection from '@/views/settings/SystemSection.vue'
</script>
<template>
<div class="pb-6">
<AccountSection />
<ChangePasswordSection />
<SystemSection />
</div>
</template>

View File

@@ -1,104 +1,100 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
const { t } = useI18n()
const claudeConnected = ref(false)
const showClaudeLoginModal = ref(false)
const apiKey = ref('')
const saved = ref(false)
const saving = ref(false)
const error = ref('')
const hasKey = ref(false)
function checkClaudeStatus() {
fetch('/aiui/api/claude/v1/messages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'haiku', messages: [{ role: 'user', content: 'ping' }] }) })
.then(r => {
if (!r.ok) { claudeConnected.value = false; return }
const reader = r.body?.getReader()
if (!reader) return
const decoder = new TextDecoder()
let text = ''
function read(): Promise<void> {
return reader!.read().then(({ done, value }) => {
if (done) {
claudeConnected.value = !text.includes('Not logged in') && !text.includes('error')
return
}
text += decoder.decode(value, { stream: true })
return read()
})
}
read()
})
.catch(() => { claudeConnected.value = false })
}
function onClaudeIframeLoad() {
window.addEventListener('message', handleClaudeLoginMessage)
}
function handleClaudeLoginMessage(e: MessageEvent) {
if (e.data?.type === 'claude-auth-success') {
claudeConnected.value = true
showClaudeLoginModal.value = false
window.removeEventListener('message', handleClaudeLoginMessage)
async function checkApiKey() {
try {
const result = await rpcClient.call({ method: 'system.settings.get', params: { key: 'claude_api_key_set' } }) as { value: boolean } | null
hasKey.value = !!result?.value
} catch {
hasKey.value = false
}
}
checkClaudeStatus()
async function saveApiKey() {
if (!apiKey.value.startsWith('sk-ant-')) {
error.value = 'API key should start with sk-ant-'
return
}
saving.value = true
error.value = ''
saved.value = false
try {
await rpcClient.call({ method: 'system.settings.set', params: { key: 'claude_api_key', value: apiKey.value } })
saved.value = true
hasKey.value = true
apiKey.value = ''
setTimeout(() => { saved.value = false }, 3000)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to save API key'
} finally {
saving.value = false
}
}
async function removeApiKey() {
try {
await rpcClient.call({ method: 'system.settings.set', params: { key: 'claude_api_key', value: '' } })
hasKey.value = false
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to remove API key'
}
}
onMounted(checkApiKey)
</script>
<template>
<!-- Claude Authentication Section -->
<div class="glass-card px-6 py-6 mb-6">
<h2 class="text-xl font-semibold text-white/96 mb-2">{{ t('settings.claudeAuth') }}</h2>
<p class="text-sm text-white/60 mb-6">{{ t('settings.claudeAuthDesc') }}</p>
<div class="bg-black/20 rounded-xl px-5 py-4 border border-white/10 mb-4" data-controller-ignore>
<div class="flex items-center gap-3 mb-2">
<svg class="w-5 h-5 shrink-0" :class="claudeConnected ? 'text-green-400' : 'text-white/40'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path v-if="claudeConnected" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 5.636a9 9 0 11-12.728 0M12 9v4m0 4h.01" />
<div class="flex items-center gap-3 mb-2">
<div class="w-10 h-10 rounded-xl bg-orange-500/20 flex items-center justify-center">
<svg class="w-5 h-5 text-orange-400" 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">{{ t('settings.connectionStatus') }}</p>
</div>
<p class="text-base font-medium" :class="claudeConnected ? 'text-green-400' : 'text-white/50'">
{{ claudeConnected ? t('common.connected') : t('settings.notConnected') }}
</p>
<div>
<h3 class="text-base font-semibold text-white/96">{{ t('settings.claudeAuth') }}</h3>
<p class="text-sm text-white/50">Enter your Anthropic API key for AI features</p>
</div>
</div>
<button
@click="showClaudeLoginModal = true"
class="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg border transition-colors"
:class="claudeConnected
? 'border-white/20 text-white/70 hover:bg-white/5'
: 'glass-button-warning font-medium'"
>
<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="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
</svg>
<span>{{ claudeConnected ? t('settings.reAuthenticate') : t('settings.loginWithClaude') }}</span>
</button>
</div>
<!-- Claude Login Modal -->
<Teleport to="body">
<div
v-if="showClaudeLoginModal"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md"
@click.self="showClaudeLoginModal = false"
>
<div class="glass-card p-0 max-w-lg w-full overflow-hidden" style="height: 480px">
<div class="flex items-center justify-between px-4 py-3 border-b border-white/10">
<h3 class="text-sm font-semibold text-white/80">{{ t('settings.claudeAuth') }}</h3>
<button @click="showClaudeLoginModal = false" class="text-white/50 hover:text-white/80 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 class="mt-4">
<div v-if="hasKey && !apiKey" class="flex items-center justify-between bg-white/5 rounded-lg px-4 py-3 mb-3">
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-green-400"></span>
<span class="text-sm text-white/80">API key configured</span>
</div>
<iframe
src="/claude-login"
class="w-full border-0"
style="height: calc(100% - 49px)"
@load="onClaudeIframeLoad"
/>
<button @click="removeApiKey" class="text-xs text-red-400 hover:text-red-300 transition-colors">Remove</button>
</div>
<div class="flex gap-2">
<input
v-model="apiKey"
type="password"
:placeholder="hasKey ? 'Replace existing key...' : 'sk-ant-...'"
class="flex-1 bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-sm text-white placeholder-white/30 focus:outline-none focus:border-white/30 transition-colors"
@keyup.enter="saveApiKey"
/>
<button
@click="saveApiKey"
:disabled="!apiKey || saving"
class="glass-button px-4 py-2.5 text-sm font-medium disabled:opacity-30"
>
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
<p v-if="saved" class="text-sm text-green-400 mt-2">API key saved successfully</p>
<p v-if="error" class="text-sm text-red-400 mt-2">{{ error }}</p>
</div>
</Teleport>
</div>
</template>

View File

@@ -0,0 +1,98 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
const { t } = useI18n()
interface VpnStatus {
connected: boolean
provider: string | null
interface: string | null
ip_address: string | null
hostname: string | null
peers_connected: number
bytes_in: number
bytes_out: number
}
const vpnStatus = ref<VpnStatus | null>(null)
const loading = ref(true)
const error = ref('')
async function fetchVpnStatus() {
try {
loading.value = true
error.value = ''
const result = await rpcClient.call({ method: 'vpn.status', params: {} })
vpnStatus.value = result as VpnStatus
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to get VPN status'
} finally {
loading.value = false
}
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`
}
onMounted(fetchVpnStatus)
</script>
<template>
<div class="glass-card px-6 py-6 mb-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-purple-500/20 flex items-center justify-center">
<svg class="w-5 h-5 text-purple-400" 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>
<h3 class="text-base font-semibold text-white/96">Nostr VPN</h3>
<p class="text-sm text-white/50">Mesh VPN with Nostr signaling</p>
</div>
</div>
<div v-if="vpnStatus" class="flex items-center gap-2">
<span
class="w-2.5 h-2.5 rounded-full"
:class="vpnStatus.connected ? 'bg-green-400 animate-pulse' : 'bg-white/30'"
/>
<span class="text-sm" :class="vpnStatus.connected ? 'text-green-400' : 'text-white/50'">
{{ vpnStatus.connected ? 'Connected' : 'Inactive' }}
</span>
</div>
</div>
<div v-if="loading" class="text-sm text-white/50">Loading VPN status...</div>
<div v-else-if="vpnStatus?.connected" class="grid grid-cols-2 gap-3">
<div class="bg-white/5 rounded-lg px-3 py-2">
<div class="text-xs text-white/50 mb-1">Provider</div>
<div class="text-sm font-medium text-white/90">{{ vpnStatus.provider || 'nostr-vpn' }}</div>
</div>
<div class="bg-white/5 rounded-lg px-3 py-2">
<div class="text-xs text-white/50 mb-1">Peers</div>
<div class="text-sm font-medium text-white/90">{{ vpnStatus.peers_connected }}</div>
</div>
<div v-if="vpnStatus.ip_address" class="bg-white/5 rounded-lg px-3 py-2">
<div class="text-xs text-white/50 mb-1">VPN Address</div>
<div class="text-sm font-mono text-white/90">{{ vpnStatus.ip_address }}</div>
</div>
<div v-if="vpnStatus.bytes_in || vpnStatus.bytes_out" class="bg-white/5 rounded-lg px-3 py-2">
<div class="text-xs text-white/50 mb-1">Traffic</div>
<div class="text-sm text-white/90">{{ formatBytes(vpnStatus.bytes_in) }} / {{ formatBytes(vpnStatus.bytes_out) }}</div>
</div>
</div>
<div v-else-if="!vpnStatus?.connected" class="text-sm text-white/50">
VPN will activate automatically when peers are discovered via Nostr relays.
</div>
<div v-if="error" class="text-sm text-red-400 mt-2">{{ error }}</div>
</div>
</template>