Sidebar version
detect_build_version() no longer reads /opt/archipelago/build-info.txt
first. That file was written by the ISO installer at flash time and
never rewritten by OTA or sideload, so after any binary swap the
sidebar kept advertising whatever the ISO shipped with. Now just
returns env!("CARGO_PKG_VERSION") unconditionally — always matches the
running binary.
FIPS card
The two-column grid in FipsNetworkCard.vue placed version/npub boxes
side-by-side on mobile but the anchor-status panel forced col-span-2,
creating an unbalanced empty column at every desktop width. Anchor
status moves to its own full-width row below the grid. When the
anchor is not reached, a "Reconnect" button appears next to the
status line; it calls fips.restart (45s timeout), waits 5s for the
daemon to come back, then reloads fips.status. Surfaces whether the
restart actually recovered the anchor in a status flash.
Profile picture render
Uploaded profile pictures are stored with an onion-rooted URL so
external Nostr clients can fetch them. The local browser isn't
Tor-routed though, so the <img src> silently 404'd and the UI fell
back to showing initials. Added a displayableUrl() helper on
Web5Identities.vue that rewrites http://<onion>/blob/<cid>[?...] to
same-origin /blob/<cid> for rendering, while the stored URL keeps
its onion prefix so publishing to Nostr still works for external
viewers. Pass-through for data: URLs and already-relative paths.
Identity row title
The identity list header now renders profile.display_name (when set)
and keeps identity.name as a muted parenthetical. Before, only the
internal name was shown and a user who'd customised their Nostr
display_name saw a mismatch between their own UI and what peers
rendered.
Artefacts:
archipelago 99184b95…22dc1b 40350664
archipelago-frontend-1.7.3-alpha.tar.gz 7b933cf4…74a8bc 76987031
Changelog layman-style per the saved feedback.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
635 lines
34 KiB
Vue
635 lines
34 KiB
Vue
<template>
|
|
<!-- Identity Management -->
|
|
<div class="glass-card p-6">
|
|
<!-- Desktop: side-by-side -->
|
|
<div class="hidden md: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="M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 114 0v1m-4 0a2 2 0 104 0m-5 8a2 2 0 100-4 2 2 0 000 4zm0 0c1.306 0 2.417.835 2.83 2M9 14a3.001 3.001 0 00-2.83 2M15 11h3m-3 4h2" />
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-white">{{ t('web5.identities') }}</h2>
|
|
<p class="text-xs text-white/60">{{ t('web5.identitiesDesc') }}</p>
|
|
</div>
|
|
</div>
|
|
<button @click="showCreateIdentityModal = true" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium flex items-center gap-2">
|
|
<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="M12 4v16m8-8H4" />
|
|
</svg>
|
|
Create
|
|
</button>
|
|
</div>
|
|
<!-- Mobile: stacked -->
|
|
<div class="md:hidden mb-4">
|
|
<div class="flex items-center gap-3 mb-2">
|
|
<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="M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 114 0v1m-4 0a2 2 0 104 0m-5 8a2 2 0 100-4 2 2 0 000 4zm0 0c1.306 0 2.417.835 2.83 2M9 14a3.001 3.001 0 00-2.83 2M15 11h3m-3 4h2" />
|
|
</svg>
|
|
</div>
|
|
<h2 class="text-lg font-semibold text-white">{{ t('web5.identities') }}</h2>
|
|
</div>
|
|
<p class="text-xs text-white/60 mb-3">{{ t('web5.identitiesDesc') }}</p>
|
|
<button @click="showCreateIdentityModal = true" class="w-full min-h-[44px] glass-button rounded-lg text-sm font-medium flex items-center justify-center gap-2">
|
|
<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="M12 4v16m8-8H4" />
|
|
</svg>
|
|
Create
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Loading -->
|
|
<div v-if="identitiesLoading" class="py-6 text-center">
|
|
<svg class="animate-spin h-6 w-6 text-blue-400 mx-auto mb-2" 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/50 text-sm">{{ t('common.loading') }}</p>
|
|
</div>
|
|
|
|
<!-- Empty State -->
|
|
<div v-else-if="managedIdentities.length === 0" class="py-6 text-center">
|
|
<svg class="w-12 h-12 text-white/20 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
|
</svg>
|
|
<p class="text-white/60 text-sm mb-1">{{ t('web5.noIdentities') }}</p>
|
|
<p class="text-white/40 text-xs">{{ t('web5.createFirstIdentity') }}</p>
|
|
</div>
|
|
|
|
<!-- Identity List -->
|
|
<div v-else class="space-y-3">
|
|
<div
|
|
v-for="(identity, idx) in managedIdentities"
|
|
:key="identity.id"
|
|
:class="{ 'card-stagger': showStagger }" class="flex items-center gap-4 p-4 bg-white/5 rounded-lg"
|
|
:style="{ '--stagger-index': idx }"
|
|
>
|
|
<!-- Avatar -->
|
|
<button @click="openProfileEditor(identity)" class="relative flex-shrink-0 w-10 h-10 rounded-full overflow-hidden group" title="Edit profile">
|
|
<img v-if="identity.profile?.picture" :src="displayableUrl(identity.profile.picture)" class="w-full h-full object-cover" @error="($event.target as HTMLImageElement).style.display = 'none'" />
|
|
<div v-if="!identity.profile?.picture" class="w-full h-full flex items-center justify-center" :class="{
|
|
'bg-blue-500/20': identity.purpose === 'personal',
|
|
'bg-orange-500/20': identity.purpose === 'business',
|
|
'bg-purple-500/20': identity.purpose === 'anonymous',
|
|
}">
|
|
<span class="text-sm font-bold" :class="{
|
|
'text-blue-400': identity.purpose === 'personal',
|
|
'text-orange-400': identity.purpose === 'business',
|
|
'text-purple-400': identity.purpose === 'anonymous',
|
|
}">{{ identity.name.charAt(0).toUpperCase() }}</span>
|
|
</div>
|
|
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
|
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /></svg>
|
|
</div>
|
|
</button>
|
|
|
|
<!-- Info -->
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-white font-medium text-sm">{{ identity.profile?.display_name || identity.name }}</span>
|
|
<span v-if="identity.profile?.display_name && identity.profile.display_name !== identity.name" class="text-white/40 text-xs truncate max-w-[160px]" :title="`Internal name: ${identity.name}`">({{ identity.name }})</span>
|
|
<span v-if="identity.is_default" class="text-yellow-400 text-xs" title="Default identity">★</span>
|
|
<span class="text-xs px-2 py-0.5 rounded-full capitalize" :class="{
|
|
'bg-blue-500/20 text-blue-300': identity.purpose === 'personal',
|
|
'bg-orange-500/20 text-orange-300': identity.purpose === 'business',
|
|
'bg-purple-500/20 text-purple-300': identity.purpose === 'anonymous',
|
|
}">{{ identity.purpose }}</span>
|
|
</div>
|
|
<div class="flex items-center gap-1 mt-0.5">
|
|
<p class="text-white/50 text-xs font-mono truncate" :title="identity.did">{{ identity.did }}</p>
|
|
<button @click="copyIdentityDid(identity.did)" class="shrink-0 p-0.5 rounded text-white/30 hover:text-white/70 transition-colors" title="Copy DID">
|
|
<svg class="w-3 h-3" 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>
|
|
</button>
|
|
</div>
|
|
<div v-if="identity.nostr_npub" class="flex items-center gap-1 mt-0.5">
|
|
<p class="text-white/40 text-xs font-mono truncate" :title="identity.nostr_npub">{{ identity.nostr_npub }}</p>
|
|
<button @click="copyIdentityDid(identity.nostr_npub || '')" class="shrink-0 p-0.5 rounded text-white/30 hover:text-white/70 transition-colors" title="Copy npub">
|
|
<svg class="w-3 h-3" 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>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Actions -->
|
|
<div class="flex items-center gap-1 shrink-0">
|
|
<button @click="openKeyViewer(identity)" class="p-2 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors" title="View keys">
|
|
<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="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>
|
|
</button>
|
|
<button v-if="!identity.is_default" @click="setDefaultIdentity(identity.id)" class="p-2 rounded-lg text-white/50 hover:text-yellow-400 hover:bg-white/10 transition-colors" title="Set as default">
|
|
<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="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
|
</svg>
|
|
</button>
|
|
<button @click="confirmDeleteIdentity(identity)" class="p-2 rounded-lg text-white/50 hover:text-red-400 hover:bg-white/10 transition-colors" title="Delete">
|
|
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Create Identity Modal -->
|
|
<Teleport to="body">
|
|
<div v-if="showCreateIdentityModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showCreateIdentityModal = false" @keydown.escape="showCreateIdentityModal = false">
|
|
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="create-identity-title">
|
|
<h2 id="create-identity-title" class="text-lg font-bold text-white mb-4">{{ t('web5.createIdentityTitle') }}</h2>
|
|
<div class="space-y-4">
|
|
<div>
|
|
<label class="text-white/60 text-sm block mb-1">Name</label>
|
|
<input v-model="newIdentityName" type="text" placeholder="Personal" class="w-full input-glass" />
|
|
</div>
|
|
<div>
|
|
<label class="text-white/60 text-sm block mb-1">Purpose</label>
|
|
<div class="grid grid-cols-3 gap-2">
|
|
<button
|
|
v-for="p in ['personal', 'business', 'anonymous']"
|
|
:key="p"
|
|
@click="newIdentityPurpose = p"
|
|
class="px-3 py-2 rounded-lg text-sm capitalize transition-colors border"
|
|
:class="newIdentityPurpose === p ? 'bg-white/15 border-white/30 text-white' : 'bg-white/5 border-white/10 text-white/60 hover:bg-white/10'"
|
|
>{{ p }}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-if="createIdentityError" class="mt-3 alert-error">
|
|
<p class="text-xs">{{ createIdentityError }}</p>
|
|
</div>
|
|
<div class="flex gap-3 mt-6">
|
|
<button @click="showCreateIdentityModal = false" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.cancel') }}</button>
|
|
<button @click="createIdentity" :disabled="creatingIdentity" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-blue-500/20 border-blue-500/30">
|
|
{{ creatingIdentity ? t('web5.creatingDid') : t('web5.createIdentity') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Teleport>
|
|
|
|
<!-- Delete Confirmation Modal -->
|
|
<Teleport to="body">
|
|
<div v-if="deleteIdentityTarget" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="deleteIdentityTarget = null" @keydown.escape="deleteIdentityTarget = null">
|
|
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="delete-identity-title">
|
|
<h2 id="delete-identity-title" class="text-lg font-bold text-white mb-2">{{ t('web5.deleteIdentityTitle') }}</h2>
|
|
<p class="text-white/60 text-sm mb-4">{{ t('web5.deleteIdentityConfirm') }}</p>
|
|
<div class="flex gap-3">
|
|
<button @click="deleteIdentityTarget = null" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.cancel') }}</button>
|
|
<button @click="deleteIdentity" :disabled="deletingIdentity" class="flex-1 glass-button glass-button-danger px-4 py-2 rounded-lg text-sm font-medium">
|
|
{{ deletingIdentity ? t('web5.deleting') : t('common.delete') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Teleport>
|
|
|
|
<!-- Key Viewer Modal -->
|
|
<Teleport to="body">
|
|
<div v-if="keyViewerIdentity" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeKeyViewer" @keydown.escape="closeKeyViewer">
|
|
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="key-viewer-title">
|
|
<div class="flex items-center gap-3 mb-5">
|
|
<div class="w-10 h-10 rounded-full flex items-center justify-center" :class="{
|
|
'bg-blue-500/20': keyViewerIdentity.purpose === 'personal',
|
|
'bg-orange-500/20': keyViewerIdentity.purpose === 'business',
|
|
'bg-purple-500/20': keyViewerIdentity.purpose === 'anonymous',
|
|
}">
|
|
<svg class="w-5 h-5" :class="{
|
|
'text-blue-400': keyViewerIdentity.purpose === 'personal',
|
|
'text-orange-400': keyViewerIdentity.purpose === 'business',
|
|
'text-purple-400': keyViewerIdentity.purpose === 'anonymous',
|
|
}" 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>
|
|
</div>
|
|
<div>
|
|
<h2 id="key-viewer-title" class="text-lg font-bold text-white">{{ keyViewerIdentity.name }}</h2>
|
|
<p class="text-xs text-white/50 capitalize">{{ keyViewerIdentity.purpose }} identity</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Public Keys -->
|
|
<div class="space-y-3 mb-5">
|
|
<h3 class="text-sm font-semibold text-white/80 flex items-center gap-2">
|
|
<svg class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
|
|
Public Keys
|
|
</h3>
|
|
<div class="space-y-2">
|
|
<div class="bg-black/30 rounded-lg p-3">
|
|
<div class="flex items-center justify-between mb-1">
|
|
<span class="text-xs text-white/50">DID (Ed25519)</span>
|
|
<button @click="copyKeyValue('did', keyViewerIdentity.did)" class="text-xs text-white/40 hover:text-white/80 transition-colors">{{ keyViewerCopied === 'did' ? 'Copied!' : 'Copy' }}</button>
|
|
</div>
|
|
<p class="text-xs font-mono text-white/70 break-all">{{ keyViewerIdentity.did }}</p>
|
|
</div>
|
|
<div class="bg-black/30 rounded-lg p-3">
|
|
<div class="flex items-center justify-between mb-1">
|
|
<span class="text-xs text-white/50">Ed25519 Public Key (hex)</span>
|
|
<button @click="copyKeyValue('pubkey', keyViewerIdentity.pubkey)" class="text-xs text-white/40 hover:text-white/80 transition-colors">{{ keyViewerCopied === 'pubkey' ? 'Copied!' : 'Copy' }}</button>
|
|
</div>
|
|
<p class="text-xs font-mono text-white/70 break-all">{{ keyViewerIdentity.pubkey }}</p>
|
|
</div>
|
|
<div v-if="keyViewerIdentity.nostr_npub" class="bg-black/30 rounded-lg p-3">
|
|
<div class="flex items-center justify-between mb-1">
|
|
<span class="text-xs text-white/50">Nostr npub (NIP-19)</span>
|
|
<button @click="copyKeyValue('npub', keyViewerIdentity.nostr_npub!)" class="text-xs text-white/40 hover:text-white/80 transition-colors">{{ keyViewerCopied === 'npub' ? 'Copied!' : 'Copy' }}</button>
|
|
</div>
|
|
<p class="text-xs font-mono text-white/70 break-all">{{ keyViewerIdentity.nostr_npub }}</p>
|
|
</div>
|
|
<div v-if="keyViewerIdentity.nostr_pubkey" class="bg-black/30 rounded-lg p-3">
|
|
<div class="flex items-center justify-between mb-1">
|
|
<span class="text-xs text-white/50">Nostr Public Key (hex)</span>
|
|
<button @click="copyKeyValue('nostr_hex', keyViewerIdentity.nostr_pubkey!)" class="text-xs text-white/40 hover:text-white/80 transition-colors">{{ keyViewerCopied === 'nostr_hex' ? 'Copied!' : 'Copy' }}</button>
|
|
</div>
|
|
<p class="text-xs font-mono text-white/70 break-all">{{ keyViewerIdentity.nostr_pubkey }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Private Keys Section -->
|
|
<div class="border-t border-white/10 pt-5">
|
|
<h3 class="text-sm font-semibold text-red-300/80 flex items-center gap-2 mb-3">
|
|
<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="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>
|
|
Private Keys
|
|
</h3>
|
|
<div v-if="!keyViewerPrivateKeys">
|
|
<p class="text-xs text-white/40 mb-3">Enter your login password to reveal private keys. Never share these with anyone.</p>
|
|
<div class="flex gap-2">
|
|
<input v-model="keyViewerPassword" type="password" placeholder="Password" class="flex-1 input-glass" @keydown.enter="unlockPrivateKeys" />
|
|
<button @click="unlockPrivateKeys" :disabled="!keyViewerPassword || keyViewerUnlocking" class="glass-button px-4 py-2 rounded-lg text-sm font-medium bg-red-500/10 border-red-500/20 hover:bg-red-500/20 disabled:opacity-50">
|
|
{{ keyViewerUnlocking ? 'Verifying...' : 'Unlock' }}
|
|
</button>
|
|
</div>
|
|
<p v-if="keyViewerError" class="text-red-400 text-xs mt-2">{{ keyViewerError }}</p>
|
|
</div>
|
|
<div v-else class="space-y-2">
|
|
<div class="bg-red-500/5 border border-red-500/10 rounded-lg p-3">
|
|
<div class="flex items-center justify-between mb-1">
|
|
<span class="text-xs text-red-300/60">Ed25519 Secret Key (hex)</span>
|
|
<button @click="copyKeyValue('ed25519_secret', keyViewerPrivateKeys.ed25519_secret_hex)" class="text-xs text-red-300/40 hover:text-red-300/80 transition-colors">{{ keyViewerCopied === 'ed25519_secret' ? 'Copied!' : 'Copy' }}</button>
|
|
</div>
|
|
<p class="text-xs font-mono text-red-200/70 break-all">{{ keyViewerPrivateKeys.ed25519_secret_hex }}</p>
|
|
</div>
|
|
<div v-if="keyViewerPrivateKeys.nostr_nsec" class="bg-red-500/5 border border-red-500/10 rounded-lg p-3">
|
|
<div class="flex items-center justify-between mb-1">
|
|
<span class="text-xs text-red-300/60">Nostr nsec (NIP-19)</span>
|
|
<button @click="copyKeyValue('nsec', keyViewerPrivateKeys.nostr_nsec)" class="text-xs text-red-300/40 hover:text-red-300/80 transition-colors">{{ keyViewerCopied === 'nsec' ? 'Copied!' : 'Copy' }}</button>
|
|
</div>
|
|
<p class="text-xs font-mono text-red-200/70 break-all">{{ keyViewerPrivateKeys.nostr_nsec }}</p>
|
|
</div>
|
|
<div v-if="keyViewerPrivateKeys.nostr_secret_hex" class="bg-red-500/5 border border-red-500/10 rounded-lg p-3">
|
|
<div class="flex items-center justify-between mb-1">
|
|
<span class="text-xs text-red-300/60">Nostr Secret Key (hex)</span>
|
|
<button @click="copyKeyValue('nostr_secret', keyViewerPrivateKeys.nostr_secret_hex)" class="text-xs text-red-300/40 hover:text-red-300/80 transition-colors">{{ keyViewerCopied === 'nostr_secret' ? 'Copied!' : 'Copy' }}</button>
|
|
</div>
|
|
<p class="text-xs font-mono text-red-200/70 break-all">{{ keyViewerPrivateKeys.nostr_secret_hex }}</p>
|
|
</div>
|
|
<button @click="keyViewerPrivateKeys = null" class="mt-2 text-xs text-white/40 hover:text-white/60 transition-colors">Lock private keys</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex justify-end mt-5">
|
|
<button @click="closeKeyViewer" class="glass-button px-6 py-2 rounded-lg text-sm">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Teleport>
|
|
|
|
<!-- Profile Editor Modal -->
|
|
<Teleport to="body">
|
|
<div v-if="profileEditorIdentity" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeProfileEditor" @keydown.escape="closeProfileEditor">
|
|
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="profile-editor-title">
|
|
<div class="flex items-center gap-3 mb-5">
|
|
<div class="relative w-16 h-16 rounded-full overflow-hidden bg-white/10 shrink-0">
|
|
<img v-if="profileForm.picture" :src="displayableUrl(profileForm.picture)" class="w-full h-full object-cover" @error="($event.target as HTMLImageElement).style.display = 'none'" />
|
|
<div v-else class="w-full h-full flex items-center justify-center">
|
|
<span class="text-2xl font-bold text-white/40">{{ profileEditorIdentity.name.charAt(0).toUpperCase() }}</span>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<h2 id="profile-editor-title" class="text-lg font-bold text-white">Edit Profile</h2>
|
|
<p class="text-xs text-white/50">{{ profileEditorIdentity.name }} · {{ profileEditorIdentity.purpose }}</p>
|
|
</div>
|
|
</div>
|
|
<div class="space-y-3">
|
|
<div>
|
|
<label class="text-white/60 text-xs block mb-1">Display Name</label>
|
|
<input v-model="profileForm.display_name" type="text" :placeholder="profileEditorIdentity.name" class="w-full input-glass" />
|
|
</div>
|
|
<div>
|
|
<label class="text-white/60 text-xs block mb-1">About / Bio</label>
|
|
<textarea v-model="profileForm.about" rows="3" placeholder="A short bio..." class="w-full input-glass resize-none"></textarea>
|
|
</div>
|
|
<div>
|
|
<label class="text-white/60 text-xs block mb-1">Profile Picture</label>
|
|
<div class="flex gap-2">
|
|
<input v-model="profileForm.picture" type="url" placeholder="https://… or upload below" class="flex-1 input-glass" />
|
|
<label class="glass-button px-3 py-2 rounded-lg text-xs cursor-pointer whitespace-nowrap">
|
|
{{ avatarUploading ? 'Uploading…' : 'Upload' }}
|
|
<input type="file" accept="image/*" class="hidden" :disabled="avatarUploading" @change="uploadAsset($event, 'picture')" />
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label class="text-white/60 text-xs block mb-1">Banner Image</label>
|
|
<div class="flex gap-2">
|
|
<input v-model="profileForm.banner" type="url" placeholder="https://… or upload below" class="flex-1 input-glass" />
|
|
<label class="glass-button px-3 py-2 rounded-lg text-xs cursor-pointer whitespace-nowrap">
|
|
{{ bannerUploading ? 'Uploading…' : 'Upload' }}
|
|
<input type="file" accept="image/*" class="hidden" :disabled="bannerUploading" @change="uploadAsset($event, 'banner')" />
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label class="text-white/60 text-xs block mb-1">Website</label>
|
|
<input v-model="profileForm.website" type="url" placeholder="https://..." class="w-full input-glass" />
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label class="text-white/60 text-xs block mb-1">NIP-05 (Nostr address)</label>
|
|
<input v-model="profileForm.nip05" type="text" placeholder="you@domain.com" class="w-full input-glass" />
|
|
</div>
|
|
<div>
|
|
<label class="text-white/60 text-xs block mb-1">Lightning Address (LUD-16)</label>
|
|
<input v-model="profileForm.lud16" type="text" placeholder="you@getalby.com" class="w-full input-glass" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-if="profileError" class="mt-3 alert-error"><p class="text-xs">{{ profileError }}</p></div>
|
|
<div v-if="profileSuccess" class="mt-3 alert-success"><p class="text-xs">{{ profileSuccess }}</p></div>
|
|
<div class="flex gap-3 mt-5">
|
|
<button @click="closeProfileEditor" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
|
|
<button @click="publishProfile" :disabled="profilePublishing" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium">{{ profilePublishing ? 'Saving & publishing…' : 'Save' }}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { rpcClient } from '@/api/rpc-client'
|
|
import { safeClipboardWrite } from './utils'
|
|
import type { ManagedIdentity, IdentityProfile } from './types'
|
|
|
|
const { t } = useI18n()
|
|
|
|
defineProps<{
|
|
showStagger: boolean
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
toast: [text: string]
|
|
}>()
|
|
|
|
const managedIdentities = ref<ManagedIdentity[]>([])
|
|
const identitiesLoading = ref(false)
|
|
const showCreateIdentityModal = ref(false)
|
|
const newIdentityName = ref('Personal')
|
|
const newIdentityPurpose = ref('personal')
|
|
const creatingIdentity = ref(false)
|
|
const createIdentityError = ref<string | null>(null)
|
|
const deleteIdentityTarget = ref<ManagedIdentity | null>(null)
|
|
const deletingIdentity = ref(false)
|
|
|
|
// Key viewer
|
|
const keyViewerIdentity = ref<ManagedIdentity | null>(null)
|
|
const keyViewerPrivateKeys = ref<{ ed25519_secret_hex: string; nostr_secret_hex: string; nostr_nsec: string } | null>(null)
|
|
const keyViewerPassword = ref('')
|
|
const keyViewerUnlocking = ref(false)
|
|
const keyViewerError = ref('')
|
|
const keyViewerCopied = ref<string | null>(null)
|
|
|
|
// Profile editor
|
|
const profileEditorIdentity = ref<ManagedIdentity | null>(null)
|
|
const profileForm = ref<IdentityProfile>({})
|
|
const profilePublishing = ref(false)
|
|
const avatarUploading = ref(false)
|
|
const bannerUploading = ref(false)
|
|
|
|
// The backend returns onion-based public URLs for uploaded profile
|
|
// pictures (so they're fetchable by external Nostr clients), but the
|
|
// local browser session isn't Tor-routed and can't resolve .onion hosts.
|
|
// Rewrite onion-rooted `/blob/<cid>` URLs (with or without capability
|
|
// query) to same-origin `/blob/<cid>` so they render in this UI. Data
|
|
// URLs and plain external URLs pass through untouched.
|
|
function displayableUrl(url: string | null | undefined): string {
|
|
if (!url) return ''
|
|
if (url.startsWith('data:') || url.startsWith('/')) return url
|
|
const onionMatch = url.match(/^https?:\/\/[a-z2-7]{16,56}\.onion(\/blob\/[0-9a-f]{64})(\?.*)?$/i)
|
|
if (onionMatch && onionMatch[1]) return onionMatch[1]
|
|
return url
|
|
}
|
|
|
|
// Upload to the node's blob store and drop the returned public URL into
|
|
// the profile field. The /api/blob endpoint marks these blobs public, so
|
|
// the URL served back (`public_url`, onion-rooted when Tor is up) is
|
|
// reachable by external Nostr clients fetching kind:0 metadata.
|
|
async function uploadAsset(ev: Event, field: 'picture' | 'banner') {
|
|
const input = ev.target as HTMLInputElement
|
|
const file = input?.files?.[0]
|
|
if (!file) return
|
|
const flag = field === 'picture' ? avatarUploading : bannerUploading
|
|
flag.value = true
|
|
profileError.value = ''
|
|
try {
|
|
const buf = await file.arrayBuffer()
|
|
const resp = await fetch('/api/blob', {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: {
|
|
'Content-Type': 'application/octet-stream',
|
|
'X-Blob-Mime': file.type || 'application/octet-stream',
|
|
'X-Blob-Filename': file.name,
|
|
},
|
|
body: buf,
|
|
})
|
|
if (!resp.ok) throw new Error(`upload failed: HTTP ${resp.status}`)
|
|
const { public_url, self_test_url } = await resp.json() as { public_url?: string; self_test_url?: string }
|
|
const url = public_url || self_test_url
|
|
if (!url) throw new Error('blob API returned no URL')
|
|
profileForm.value[field] = url
|
|
} catch (e: unknown) {
|
|
profileError.value = e instanceof Error ? e.message : `${field} upload failed`
|
|
} finally {
|
|
flag.value = false
|
|
// Clear the input so selecting the same file again re-fires change.
|
|
if (input) input.value = ''
|
|
}
|
|
}
|
|
const profileError = ref('')
|
|
const profileSuccess = ref('')
|
|
|
|
async function loadIdentities() {
|
|
identitiesLoading.value = true
|
|
try {
|
|
const res = await rpcClient.call<{ identities: ManagedIdentity[] }>({ method: 'identity.list' })
|
|
managedIdentities.value = res.identities || []
|
|
} catch {
|
|
managedIdentities.value = []
|
|
} finally {
|
|
identitiesLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function createIdentity() {
|
|
if (creatingIdentity.value) return
|
|
createIdentityError.value = null
|
|
creatingIdentity.value = true
|
|
try {
|
|
await rpcClient.call({
|
|
method: 'identity.create',
|
|
params: { name: newIdentityName.value.trim() || 'Personal', purpose: newIdentityPurpose.value },
|
|
})
|
|
showCreateIdentityModal.value = false
|
|
newIdentityName.value = 'Personal'
|
|
newIdentityPurpose.value = 'personal'
|
|
await loadIdentities()
|
|
emit('toast', t('web5.identityCreated'))
|
|
} catch (err: unknown) {
|
|
createIdentityError.value = err instanceof Error ? err.message : t('web5.failedToCreateIdentity')
|
|
} finally {
|
|
creatingIdentity.value = false
|
|
}
|
|
}
|
|
|
|
function copyIdentityDid(did: string) {
|
|
safeClipboardWrite(did)
|
|
emit('toast', t('web5.didCopied'))
|
|
}
|
|
|
|
async function setDefaultIdentity(id: string) {
|
|
try {
|
|
await rpcClient.call({ method: 'identity.set-default', params: { id } })
|
|
await loadIdentities()
|
|
emit('toast', t('web5.defaultIdentityUpdated'))
|
|
} catch {
|
|
emit('toast', t('web5.failedToSetDefault'))
|
|
}
|
|
}
|
|
|
|
function confirmDeleteIdentity(identity: ManagedIdentity) {
|
|
deleteIdentityTarget.value = identity
|
|
}
|
|
|
|
async function deleteIdentity() {
|
|
if (!deleteIdentityTarget.value || deletingIdentity.value) return
|
|
deletingIdentity.value = true
|
|
try {
|
|
await rpcClient.call({ method: 'identity.delete', params: { id: deleteIdentityTarget.value.id } })
|
|
deleteIdentityTarget.value = null
|
|
await loadIdentities()
|
|
emit('toast', t('web5.identityDeleted'))
|
|
} catch {
|
|
emit('toast', t('web5.failedToDeleteIdentity'))
|
|
} finally {
|
|
deletingIdentity.value = false
|
|
}
|
|
}
|
|
|
|
function openKeyViewer(identity: ManagedIdentity) {
|
|
keyViewerIdentity.value = identity
|
|
keyViewerPrivateKeys.value = null
|
|
keyViewerPassword.value = ''
|
|
keyViewerError.value = ''
|
|
}
|
|
|
|
function closeKeyViewer() {
|
|
keyViewerPrivateKeys.value = null
|
|
keyViewerPassword.value = ''
|
|
keyViewerError.value = ''
|
|
keyViewerIdentity.value = null
|
|
}
|
|
|
|
async function unlockPrivateKeys() {
|
|
if (!keyViewerIdentity.value || !keyViewerPassword.value || keyViewerUnlocking.value) return
|
|
keyViewerUnlocking.value = true
|
|
keyViewerError.value = ''
|
|
try {
|
|
const res = await rpcClient.call<{
|
|
ed25519_secret_hex: string
|
|
nostr_secret_hex: string | null
|
|
nostr_nsec: string | null
|
|
}>({
|
|
method: 'identity.export-keys',
|
|
params: { id: keyViewerIdentity.value.id, password: keyViewerPassword.value },
|
|
})
|
|
keyViewerPrivateKeys.value = {
|
|
ed25519_secret_hex: res.ed25519_secret_hex,
|
|
nostr_secret_hex: res.nostr_secret_hex || '',
|
|
nostr_nsec: res.nostr_nsec || '',
|
|
}
|
|
keyViewerPassword.value = ''
|
|
} catch (err: unknown) {
|
|
keyViewerError.value = err instanceof Error ? err.message : 'Failed to unlock keys'
|
|
} finally {
|
|
keyViewerUnlocking.value = false
|
|
}
|
|
}
|
|
|
|
function copyKeyValue(label: string, value: string) {
|
|
safeClipboardWrite(value)
|
|
keyViewerCopied.value = label
|
|
setTimeout(() => { keyViewerCopied.value = null }, 2000)
|
|
}
|
|
|
|
function openProfileEditor(identity: ManagedIdentity) {
|
|
profileEditorIdentity.value = identity
|
|
profileForm.value = { ...identity.profile }
|
|
profileError.value = ''
|
|
profileSuccess.value = ''
|
|
}
|
|
|
|
function closeProfileEditor() {
|
|
profileEditorIdentity.value = null
|
|
profileForm.value = {}
|
|
profileError.value = ''
|
|
profileSuccess.value = ''
|
|
}
|
|
|
|
async function publishProfile() {
|
|
if (!profileEditorIdentity.value || profilePublishing.value) return
|
|
profilePublishing.value = true
|
|
profileError.value = ''
|
|
profileSuccess.value = ''
|
|
try {
|
|
await rpcClient.call({
|
|
method: 'identity.update-profile',
|
|
params: { id: profileEditorIdentity.value.id, ...profileForm.value },
|
|
})
|
|
const res = await rpcClient.call<{
|
|
event_id: string
|
|
accepted: string[]
|
|
rejected: Array<[string, string]>
|
|
relays_attempted: number
|
|
published: boolean
|
|
}>({
|
|
method: 'identity.publish-profile',
|
|
params: { id: profileEditorIdentity.value.id },
|
|
})
|
|
await loadIdentities()
|
|
const n = res.accepted?.length ?? 0
|
|
const total = res.relays_attempted ?? 0
|
|
const tail = `(${res.event_id.slice(0, 12)}…)`
|
|
if (n === total) {
|
|
profileSuccess.value = `Published to all ${total} relays ${tail}`
|
|
} else if (n > 0) {
|
|
profileSuccess.value = `Published to ${n}/${total} relays ${tail}`
|
|
const first = res.rejected?.[0]
|
|
if (first) profileError.value = `Rejected by ${first[0]}: ${first[1]}`
|
|
} else {
|
|
profileError.value = `Published to 0/${total} relays — check Manage Relays`
|
|
}
|
|
setTimeout(() => { profileSuccess.value = '' }, 5000)
|
|
} catch (err: unknown) {
|
|
profileError.value = err instanceof Error ? err.message : 'Failed to publish'
|
|
} finally {
|
|
profilePublishing.value = false
|
|
}
|
|
}
|
|
|
|
defineExpose({ loadIdentities, managedIdentities })
|
|
</script>
|