Follow-up to 1fb71b4b on the same v1.7.0-alpha line.
Identity avatars
• New module `avatar.rs` generates two deterministic SVG styles keyed
off the pubkey: a 5×5 mirrored identicon for sub-identities and a
hexagonal-network motif for the master (seed index 0) identity.
Both returned as base64 data URLs, so a fresh identity has a
recognisable picture before the user uploads anything.
• `IdentityManager::create()` and `create_from_seed()` populate
`profile.picture` on creation. Index 0 gets the node SVG; all
other seed-derived + ad-hoc identities get the identicon.
Blob store — public flag for profile assets
• `BlobMeta.public` (default false) added; `BlobStore::put()` takes
a `public: bool`. Missing in legacy meta files = false.
• `POST /api/blob` now stores uploads with public=true and returns
`public_url` alongside `self_test_url`. public_url is
`http://<node-onion>/blob/<cid>` (no cap) if Tor has published the
archipelago hidden service, else falls back to the local path.
• `GET /blob/<cid>` bypasses the HMAC capability check when the
requested blob is flagged public — external Nostr clients fetching
a kind-0 `picture` URL can't hold a cap.
• Mesh callers (content_ref attachments, dispatch rehydration) pin
public=false explicitly so nothing leaks out of the mesh path.
Profile editor UX
• Collapsed Save + Save & Publish into one button — the Save action
now persists locally AND publishes the kind-0 metadata event in
one step. Uploads store `public_url` into `profile.picture` /
`profile.banner` so the published URL is reachable by external
clients.
Update client — the 15-second cliff
• Frontend `rpcClient.call` for `update.download` now has an
explicit 30-minute timeout (was falling back to the default 15 s).
`update.apply` gets 5 min, `update.git-apply` gets 15 min. Matches
what the backend is actually willing to wait for.
• Backend `load_state()` reconciles `state.current_version` with
`CARGO_PKG_VERSION` on every start. Sideloaded or reflashed nodes
were stuck advertising the old version even with a new binary in
place, which kept re-offering the same release as an update.
Manifest changelog rewritten for fleet readers per the saved feedback
(no function names, no file paths). Artefacts refreshed:
binary 12f838c5…5ba82d 40381864
frontend dc3b63af…e9a8370 76984288
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
620 lines
32 KiB
Vue
620 lines
32 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="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.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="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)
|
|
|
|
// 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>
|