archy/neode-ui/src/views/web5/Web5Identities.vue

725 lines
37 KiB
Vue
Raw Normal View History

<template>
<!-- Identity Management -->
fix(ui): mesh/web5/apps layout, modal, and search UX fixes - Mesh: fix 920-1280px bottom margin (phantom mobile-nav reservation leaking into the desktop-sidebar range), let the mesh view scale to full width on wide screens instead of capping at 1600px, and make the Device panel collapsible on desktop (previously mobile-only) - Search/controller-nav: a global gamepad/keyboard-nav feature was auto-clicking "the next button in the DOM" on Enter in any text input, which cleared the mesh peer search and popped the sideload modal from the App Store/My Apps search boxes. Opt out via data-controller-no-submit on all filter inputs; bump the mesh clear button's touch target - Modals: several (sideload, credential, Lightning channel open, identity create) used ad-hoc blue buttons and non-fullscreen backdrops that only covered the main content area, not the sidebar. Teleport them to body, unify backdrop/button theming to the dark+orange convention, fix the sideload modal's square bottom corners on desktop, and standardize close buttons to the ghost-icon style - Web5: remove the redundant/dead "Messages" tab from Connected Nodes (its deep-link was unreachable dead code); fix the "view message" toast to actually open the Archipelago channel instead of silently failing to match a LoRa peer; make identity rows responsive via a container query (viewport-based breakpoints don't work in the page's 2-column grid) and right-justify their action icons; collapse DID/DHT/Wallet/Nostr/Connected Nodes by default on mobile - Apps/App Store: match the search bar and sideload button's height, padding, and background to the mode-switcher tabs beside them - Mesh chat: keep the compose input focused after sending Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 18:04:31 -04:00
<div class="glass-card p-6 identities-card">
<!-- 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="web5-card-actions-top glass-button glass-button-sm px-3 rounded-lg text-sm font-medium items-center gap-2">
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>
</div>
<!-- Loading -->
<div v-if="identitiesLoading && managedIdentities.length === 0" 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-if="identitiesLoading" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
<svg class="animate-spin h-3.5 w-3.5" 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>
Refreshing identities...
</div>
<div
v-for="(identity, idx) in managedIdentities"
:key="identity.id"
fix(ui): mesh/web5/apps layout, modal, and search UX fixes - Mesh: fix 920-1280px bottom margin (phantom mobile-nav reservation leaking into the desktop-sidebar range), let the mesh view scale to full width on wide screens instead of capping at 1600px, and make the Device panel collapsible on desktop (previously mobile-only) - Search/controller-nav: a global gamepad/keyboard-nav feature was auto-clicking "the next button in the DOM" on Enter in any text input, which cleared the mesh peer search and popped the sideload modal from the App Store/My Apps search boxes. Opt out via data-controller-no-submit on all filter inputs; bump the mesh clear button's touch target - Modals: several (sideload, credential, Lightning channel open, identity create) used ad-hoc blue buttons and non-fullscreen backdrops that only covered the main content area, not the sidebar. Teleport them to body, unify backdrop/button theming to the dark+orange convention, fix the sideload modal's square bottom corners on desktop, and standardize close buttons to the ghost-icon style - Web5: remove the redundant/dead "Messages" tab from Connected Nodes (its deep-link was unreachable dead code); fix the "view message" toast to actually open the Archipelago channel instead of silently failing to match a LoRa peer; make identity rows responsive via a container query (viewport-based breakpoints don't work in the page's 2-column grid) and right-justify their action icons; collapse DID/DHT/Wallet/Nostr/Connected Nodes by default on mobile - Apps/App Store: match the search bar and sideload button's height, padding, and background to the mode-switcher tabs beside them - Mesh chat: keep the compose input focused after sending Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 18:04:31 -04:00
:class="{ 'card-stagger': showStagger }" class="identity-row flex flex-col gap-3 p-4 bg-white/[0.08] rounded-lg"
:style="{ '--stagger-index': idx }"
>
fix(ui): mesh/web5/apps layout, modal, and search UX fixes - Mesh: fix 920-1280px bottom margin (phantom mobile-nav reservation leaking into the desktop-sidebar range), let the mesh view scale to full width on wide screens instead of capping at 1600px, and make the Device panel collapsible on desktop (previously mobile-only) - Search/controller-nav: a global gamepad/keyboard-nav feature was auto-clicking "the next button in the DOM" on Enter in any text input, which cleared the mesh peer search and popped the sideload modal from the App Store/My Apps search boxes. Opt out via data-controller-no-submit on all filter inputs; bump the mesh clear button's touch target - Modals: several (sideload, credential, Lightning channel open, identity create) used ad-hoc blue buttons and non-fullscreen backdrops that only covered the main content area, not the sidebar. Teleport them to body, unify backdrop/button theming to the dark+orange convention, fix the sideload modal's square bottom corners on desktop, and standardize close buttons to the ghost-icon style - Web5: remove the redundant/dead "Messages" tab from Connected Nodes (its deep-link was unreachable dead code); fix the "view message" toast to actually open the Archipelago channel instead of silently failing to match a LoRa peer; make identity rows responsive via a container query (viewport-based breakpoints don't work in the page's 2-column grid) and right-justify their action icons; collapse DID/DHT/Wallet/Nostr/Connected Nodes by default on mobile - Apps/App Store: match the search bar and sideload button's height, padding, and background to the mode-switcher tabs beside them - Mesh chat: keep the compose input focused after sending Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 18:04:31 -04:00
<div class="identity-row-main flex items-center gap-4 min-w-0">
<!-- Avatar -->
<button @click="openProfileEditor(identity)" class="relative flex-shrink-0 w-10 h-10 rounded-full overflow-hidden group" title="Edit profile">
release(v1.7.10-alpha): apply namespace fix + FIPS cascade + profile polish THE apply fix archipelago.service uses ProtectSystem=strict, so /opt and /usr are read-only inside the service's mount namespace. sudo inherits that namespace — every sudo mkdir/mv/chown from apply_update was hitting EROFS even as root. Every prior "Failed to apply update" was a symptom of this. New `host_sudo()` helper wraps every filesystem call in `sudo systemd-run --wait --collect --pipe -- <cmd>`, which spawns a transient unit with systemd's default (no ProtectSystem) protections — the command runs in the host namespace and can touch /opt/archipelago + /usr/local/bin normally. FIPS cascade (#2) Home.vue and Server.vue both carry a FIPS row that previously only looked at {installed, service_active, key_present}. Now they also read anchor_connected + authenticated_peer_count and mirror the full FIPS card: green "Active · N peers" when healthy, orange "No anchor" when the DHT bootstrap has failed. Profile paste URL fallback (#4) Web5Identities.vue list + editor previously had `@error="display:none"` on the <img>, which hid the tag without re-rendering the fallback — a broken pasted URL showed up blank. Replaced with reactive pictureLoadFailed / listPictureFailed flags plus a watcher that resets on URL change. Broken URL now falls back to the initial (or identicon for seed-derived identities). Small-upload data URL (#3) Uploaded profile pictures ≤ 64 KB are now inlined as `data:image/png;base64,...` into profile.picture on the client before calling update-profile. That kind-0 event is fetchable by any Nostr client — no Tor needed. Larger uploads fall back to the onion-rooted public_url with a hint telling the user to paste a public https:// URL for broader visibility. Deferred: #1 FIPS Reconnect "actually fixes" — the current Reconnect calls fips.restart which clears the daemon state, but when the anchor is truly unreachable (UDP 8668 blocked by network/ISP), no amount of restart can help. A richer diagnostic is out of scope for this bundle. Artefacts: archipelago 4a77c704…82aa6f8 40379696 archipelago-frontend-1.7.10-alpha.tar.gz 0644a436…54f58 76983846 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:46:03 -04:00
<img
v-if="identity.profile?.picture && !listPictureFailed[identity.id]"
:src="displayableUrl(identity.profile.picture)"
class="w-full h-full object-cover"
@error="() => { listPictureFailed[identity.id] = true }"
/>
<div v-if="!identity.profile?.picture || listPictureFailed[identity.id]" 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">
release(v1.7.3-alpha): sidebar version sync + FIPS reconnect + profile pic render 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>
2026-04-20 11:44:59 -04:00
<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">&#9733;</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>
fix(ui): mesh/web5/apps layout, modal, and search UX fixes - Mesh: fix 920-1280px bottom margin (phantom mobile-nav reservation leaking into the desktop-sidebar range), let the mesh view scale to full width on wide screens instead of capping at 1600px, and make the Device panel collapsible on desktop (previously mobile-only) - Search/controller-nav: a global gamepad/keyboard-nav feature was auto-clicking "the next button in the DOM" on Enter in any text input, which cleared the mesh peer search and popped the sideload modal from the App Store/My Apps search boxes. Opt out via data-controller-no-submit on all filter inputs; bump the mesh clear button's touch target - Modals: several (sideload, credential, Lightning channel open, identity create) used ad-hoc blue buttons and non-fullscreen backdrops that only covered the main content area, not the sidebar. Teleport them to body, unify backdrop/button theming to the dark+orange convention, fix the sideload modal's square bottom corners on desktop, and standardize close buttons to the ghost-icon style - Web5: remove the redundant/dead "Messages" tab from Connected Nodes (its deep-link was unreachable dead code); fix the "view message" toast to actually open the Archipelago channel instead of silently failing to match a LoRa peer; make identity rows responsive via a container query (viewport-based breakpoints don't work in the page's 2-column grid) and right-justify their action icons; collapse DID/DHT/Wallet/Nostr/Connected Nodes by default on mobile - Apps/App Store: match the search bar and sideload button's height, padding, and background to the mode-switcher tabs beside them - Mesh chat: keep the compose input focused after sending Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 18:04:31 -04:00
</div>
<!-- Actions -->
fix(ui): mesh/web5/apps layout, modal, and search UX fixes - Mesh: fix 920-1280px bottom margin (phantom mobile-nav reservation leaking into the desktop-sidebar range), let the mesh view scale to full width on wide screens instead of capping at 1600px, and make the Device panel collapsible on desktop (previously mobile-only) - Search/controller-nav: a global gamepad/keyboard-nav feature was auto-clicking "the next button in the DOM" on Enter in any text input, which cleared the mesh peer search and popped the sideload modal from the App Store/My Apps search boxes. Opt out via data-controller-no-submit on all filter inputs; bump the mesh clear button's touch target - Modals: several (sideload, credential, Lightning channel open, identity create) used ad-hoc blue buttons and non-fullscreen backdrops that only covered the main content area, not the sidebar. Teleport them to body, unify backdrop/button theming to the dark+orange convention, fix the sideload modal's square bottom corners on desktop, and standardize close buttons to the ghost-icon style - Web5: remove the redundant/dead "Messages" tab from Connected Nodes (its deep-link was unreachable dead code); fix the "view message" toast to actually open the Archipelago channel instead of silently failing to match a LoRa peer; make identity rows responsive via a container query (viewport-based breakpoints don't work in the page's 2-column grid) and right-justify their action icons; collapse DID/DHT/Wallet/Nostr/Connected Nodes by default on mobile - Apps/App Store: match the search bar and sideload button's height, padding, and background to the mode-switcher tabs beside them - Mesh chat: keep the compose input focused after sending Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 18:04:31 -04:00
<div class="flex items-center justify-end 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>
<button @click="showCreateIdentityModal = true" class="web5-card-actions-bottom mt-4 mobile-card-action glass-button rounded-lg text-sm font-medium">
Create
</button>
</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>
fix(ui): mesh/web5/apps layout, modal, and search UX fixes - Mesh: fix 920-1280px bottom margin (phantom mobile-nav reservation leaking into the desktop-sidebar range), let the mesh view scale to full width on wide screens instead of capping at 1600px, and make the Device panel collapsible on desktop (previously mobile-only) - Search/controller-nav: a global gamepad/keyboard-nav feature was auto-clicking "the next button in the DOM" on Enter in any text input, which cleared the mesh peer search and popped the sideload modal from the App Store/My Apps search boxes. Opt out via data-controller-no-submit on all filter inputs; bump the mesh clear button's touch target - Modals: several (sideload, credential, Lightning channel open, identity create) used ad-hoc blue buttons and non-fullscreen backdrops that only covered the main content area, not the sidebar. Teleport them to body, unify backdrop/button theming to the dark+orange convention, fix the sideload modal's square bottom corners on desktop, and standardize close buttons to the ghost-icon style - Web5: remove the redundant/dead "Messages" tab from Connected Nodes (its deep-link was unreachable dead code); fix the "view message" toast to actually open the Archipelago channel instead of silently failing to match a LoRa peer; make identity rows responsive via a container query (viewport-based breakpoints don't work in the page's 2-column grid) and right-justify their action icons; collapse DID/DHT/Wallet/Nostr/Connected Nodes by default on mobile - Apps/App Store: match the search bar and sideload button's height, padding, and background to the mode-switcher tabs beside them - Mesh chat: keep the compose input focused after sending Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 18:04:31 -04:00
<button @click="createIdentity" :disabled="creatingIdentity" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium">
{{ 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">
release(v1.7.10-alpha): apply namespace fix + FIPS cascade + profile polish THE apply fix archipelago.service uses ProtectSystem=strict, so /opt and /usr are read-only inside the service's mount namespace. sudo inherits that namespace — every sudo mkdir/mv/chown from apply_update was hitting EROFS even as root. Every prior "Failed to apply update" was a symptom of this. New `host_sudo()` helper wraps every filesystem call in `sudo systemd-run --wait --collect --pipe -- <cmd>`, which spawns a transient unit with systemd's default (no ProtectSystem) protections — the command runs in the host namespace and can touch /opt/archipelago + /usr/local/bin normally. FIPS cascade (#2) Home.vue and Server.vue both carry a FIPS row that previously only looked at {installed, service_active, key_present}. Now they also read anchor_connected + authenticated_peer_count and mirror the full FIPS card: green "Active · N peers" when healthy, orange "No anchor" when the DHT bootstrap has failed. Profile paste URL fallback (#4) Web5Identities.vue list + editor previously had `@error="display:none"` on the <img>, which hid the tag without re-rendering the fallback — a broken pasted URL showed up blank. Replaced with reactive pictureLoadFailed / listPictureFailed flags plus a watcher that resets on URL change. Broken URL now falls back to the initial (or identicon for seed-derived identities). Small-upload data URL (#3) Uploaded profile pictures ≤ 64 KB are now inlined as `data:image/png;base64,...` into profile.picture on the client before calling update-profile. That kind-0 event is fetchable by any Nostr client — no Tor needed. Larger uploads fall back to the onion-rooted public_url with a hint telling the user to paste a public https:// URL for broader visibility. Deferred: #1 FIPS Reconnect "actually fixes" — the current Reconnect calls fips.restart which clears the daemon state, but when the anchor is truly unreachable (UDP 8668 blocked by network/ISP), no amount of restart can help. A richer diagnostic is out of scope for this bundle. Artefacts: archipelago 4a77c704…82aa6f8 40379696 archipelago-frontend-1.7.10-alpha.tar.gz 0644a436…54f58 76983846 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:46:03 -04:00
<img
v-if="profileForm.picture && !editorPictureFailed"
:src="displayableUrl(profileForm.picture)"
class="w-full h-full object-cover"
@error="editorPictureFailed = true"
@load="editorPictureFailed = false"
/>
<div v-if="!profileForm.picture || editorPictureFailed" 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 }} &middot; {{ 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>
feat(identity,update): default avatars, public blobs, long-running downloads 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>
2026-04-20 10:03:38 -04:00
<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">
release(v1.7.10-alpha): apply namespace fix + FIPS cascade + profile polish THE apply fix archipelago.service uses ProtectSystem=strict, so /opt and /usr are read-only inside the service's mount namespace. sudo inherits that namespace — every sudo mkdir/mv/chown from apply_update was hitting EROFS even as root. Every prior "Failed to apply update" was a symptom of this. New `host_sudo()` helper wraps every filesystem call in `sudo systemd-run --wait --collect --pipe -- <cmd>`, which spawns a transient unit with systemd's default (no ProtectSystem) protections — the command runs in the host namespace and can touch /opt/archipelago + /usr/local/bin normally. FIPS cascade (#2) Home.vue and Server.vue both carry a FIPS row that previously only looked at {installed, service_active, key_present}. Now they also read anchor_connected + authenticated_peer_count and mirror the full FIPS card: green "Active · N peers" when healthy, orange "No anchor" when the DHT bootstrap has failed. Profile paste URL fallback (#4) Web5Identities.vue list + editor previously had `@error="display:none"` on the <img>, which hid the tag without re-rendering the fallback — a broken pasted URL showed up blank. Replaced with reactive pictureLoadFailed / listPictureFailed flags plus a watcher that resets on URL change. Broken URL now falls back to the initial (or identicon for seed-derived identities). Small-upload data URL (#3) Uploaded profile pictures ≤ 64 KB are now inlined as `data:image/png;base64,...` into profile.picture on the client before calling update-profile. That kind-0 event is fetchable by any Nostr client — no Tor needed. Larger uploads fall back to the onion-rooted public_url with a hint telling the user to paste a public https:// URL for broader visibility. Deferred: #1 FIPS Reconnect "actually fixes" — the current Reconnect calls fips.restart which clears the daemon state, but when the anchor is truly unreachable (UDP 8668 blocked by network/ISP), no amount of restart can help. A richer diagnostic is out of scope for this bundle. Artefacts: archipelago 4a77c704…82aa6f8 40379696 archipelago-frontend-1.7.10-alpha.tar.gz 0644a436…54f58 76983846 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:46:03 -04:00
import { reactive, ref, watch } 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()
2026-06-12 04:21:18 -04:00
const IDENTITIES_CACHE_KEY = 'archipelago.web5.identities.v1'
function readIdentitiesCache(): ManagedIdentity[] {
if (typeof window === 'undefined') return []
try {
const raw = window.sessionStorage.getItem(IDENTITIES_CACHE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw) as ManagedIdentity[]
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
function writeIdentitiesCache(identities: ManagedIdentity[]) {
if (typeof window === 'undefined') return
try {
window.sessionStorage.setItem(IDENTITIES_CACHE_KEY, JSON.stringify(identities))
} catch {
// Cache is opportunistic only.
}
}
defineProps<{
showStagger: boolean
}>()
const emit = defineEmits<{
toast: [text: string]
}>()
2026-06-12 04:21:18 -04:00
const managedIdentities = ref<ManagedIdentity[]>(readIdentitiesCache())
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)
release(v1.7.10-alpha): apply namespace fix + FIPS cascade + profile polish THE apply fix archipelago.service uses ProtectSystem=strict, so /opt and /usr are read-only inside the service's mount namespace. sudo inherits that namespace — every sudo mkdir/mv/chown from apply_update was hitting EROFS even as root. Every prior "Failed to apply update" was a symptom of this. New `host_sudo()` helper wraps every filesystem call in `sudo systemd-run --wait --collect --pipe -- <cmd>`, which spawns a transient unit with systemd's default (no ProtectSystem) protections — the command runs in the host namespace and can touch /opt/archipelago + /usr/local/bin normally. FIPS cascade (#2) Home.vue and Server.vue both carry a FIPS row that previously only looked at {installed, service_active, key_present}. Now they also read anchor_connected + authenticated_peer_count and mirror the full FIPS card: green "Active · N peers" when healthy, orange "No anchor" when the DHT bootstrap has failed. Profile paste URL fallback (#4) Web5Identities.vue list + editor previously had `@error="display:none"` on the <img>, which hid the tag without re-rendering the fallback — a broken pasted URL showed up blank. Replaced with reactive pictureLoadFailed / listPictureFailed flags plus a watcher that resets on URL change. Broken URL now falls back to the initial (or identicon for seed-derived identities). Small-upload data URL (#3) Uploaded profile pictures ≤ 64 KB are now inlined as `data:image/png;base64,...` into profile.picture on the client before calling update-profile. That kind-0 event is fetchable by any Nostr client — no Tor needed. Larger uploads fall back to the onion-rooted public_url with a hint telling the user to paste a public https:// URL for broader visibility. Deferred: #1 FIPS Reconnect "actually fixes" — the current Reconnect calls fips.restart which clears the daemon state, but when the anchor is truly unreachable (UDP 8668 blocked by network/ISP), no amount of restart can help. A richer diagnostic is out of scope for this bundle. Artefacts: archipelago 4a77c704…82aa6f8 40379696 archipelago-frontend-1.7.10-alpha.tar.gz 0644a436…54f58 76983846 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:46:03 -04:00
// Track image load failures so the UI can fall back to the initial/
// identicon placeholder instead of showing a blank square. Pasted URLs
// that 404 (or point at an onion the local browser can't reach) were
// previously silently hidden by a display:none handler that left the
// fallback unrendered.
const editorPictureFailed = ref(false)
const listPictureFailed = reactive<Record<string, boolean>>({})
// Reset the failure flag when the URL changes so a freshly pasted URL
// gets re-tried (the watcher fires once the form reacts).
watch(() => profileForm.value.picture, () => { editorPictureFailed.value = false })
release(v1.7.3-alpha): sidebar version sync + FIPS reconnect + profile pic render 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>
2026-04-20 11:44:59 -04:00
// 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
}
release(v1.7.10-alpha): apply namespace fix + FIPS cascade + profile polish THE apply fix archipelago.service uses ProtectSystem=strict, so /opt and /usr are read-only inside the service's mount namespace. sudo inherits that namespace — every sudo mkdir/mv/chown from apply_update was hitting EROFS even as root. Every prior "Failed to apply update" was a symptom of this. New `host_sudo()` helper wraps every filesystem call in `sudo systemd-run --wait --collect --pipe -- <cmd>`, which spawns a transient unit with systemd's default (no ProtectSystem) protections — the command runs in the host namespace and can touch /opt/archipelago + /usr/local/bin normally. FIPS cascade (#2) Home.vue and Server.vue both carry a FIPS row that previously only looked at {installed, service_active, key_present}. Now they also read anchor_connected + authenticated_peer_count and mirror the full FIPS card: green "Active · N peers" when healthy, orange "No anchor" when the DHT bootstrap has failed. Profile paste URL fallback (#4) Web5Identities.vue list + editor previously had `@error="display:none"` on the <img>, which hid the tag without re-rendering the fallback — a broken pasted URL showed up blank. Replaced with reactive pictureLoadFailed / listPictureFailed flags plus a watcher that resets on URL change. Broken URL now falls back to the initial (or identicon for seed-derived identities). Small-upload data URL (#3) Uploaded profile pictures ≤ 64 KB are now inlined as `data:image/png;base64,...` into profile.picture on the client before calling update-profile. That kind-0 event is fetchable by any Nostr client — no Tor needed. Larger uploads fall back to the onion-rooted public_url with a hint telling the user to paste a public https:// URL for broader visibility. Deferred: #1 FIPS Reconnect "actually fixes" — the current Reconnect calls fips.restart which clears the daemon state, but when the anchor is truly unreachable (UDP 8668 blocked by network/ISP), no amount of restart can help. A richer diagnostic is out of scope for this bundle. Artefacts: archipelago 4a77c704…82aa6f8 40379696 archipelago-frontend-1.7.10-alpha.tar.gz 0644a436…54f58 76983846 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:46:03 -04:00
// Upload to the node's blob store and drop a URL into the profile field.
// For small images (≤64KB) we inline the bytes as a data URL so external
// Nostr clients can render the picture without needing to reach a tor
// onion. Larger uploads fall back to the onion-rooted public_url.
const INLINE_MAX = 64 * 1024
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()
release(v1.7.10-alpha): apply namespace fix + FIPS cascade + profile polish THE apply fix archipelago.service uses ProtectSystem=strict, so /opt and /usr are read-only inside the service's mount namespace. sudo inherits that namespace — every sudo mkdir/mv/chown from apply_update was hitting EROFS even as root. Every prior "Failed to apply update" was a symptom of this. New `host_sudo()` helper wraps every filesystem call in `sudo systemd-run --wait --collect --pipe -- <cmd>`, which spawns a transient unit with systemd's default (no ProtectSystem) protections — the command runs in the host namespace and can touch /opt/archipelago + /usr/local/bin normally. FIPS cascade (#2) Home.vue and Server.vue both carry a FIPS row that previously only looked at {installed, service_active, key_present}. Now they also read anchor_connected + authenticated_peer_count and mirror the full FIPS card: green "Active · N peers" when healthy, orange "No anchor" when the DHT bootstrap has failed. Profile paste URL fallback (#4) Web5Identities.vue list + editor previously had `@error="display:none"` on the <img>, which hid the tag without re-rendering the fallback — a broken pasted URL showed up blank. Replaced with reactive pictureLoadFailed / listPictureFailed flags plus a watcher that resets on URL change. Broken URL now falls back to the initial (or identicon for seed-derived identities). Small-upload data URL (#3) Uploaded profile pictures ≤ 64 KB are now inlined as `data:image/png;base64,...` into profile.picture on the client before calling update-profile. That kind-0 event is fetchable by any Nostr client — no Tor needed. Larger uploads fall back to the onion-rooted public_url with a hint telling the user to paste a public https:// URL for broader visibility. Deferred: #1 FIPS Reconnect "actually fixes" — the current Reconnect calls fips.restart which clears the daemon state, but when the anchor is truly unreachable (UDP 8668 blocked by network/ISP), no amount of restart can help. A richer diagnostic is out of scope for this bundle. Artefacts: archipelago 4a77c704…82aa6f8 40379696 archipelago-frontend-1.7.10-alpha.tar.gz 0644a436…54f58 76983846 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:46:03 -04:00
// Inline small images as a data URL — universally fetchable by any
// Nostr client and bypasses the "only reachable over Tor" limitation.
if (buf.byteLength <= INLINE_MAX) {
const mime = file.type || 'image/png'
const b64 = btoa(Array.from(new Uint8Array(buf), (b) => String.fromCharCode(b)).join(''))
profileForm.value[field] = `data:${mime};base64,${b64}`
return
}
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}`)
feat(identity,update): default avatars, public blobs, long-running downloads 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>
2026-04-20 10:03:38 -04:00
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
release(v1.7.10-alpha): apply namespace fix + FIPS cascade + profile polish THE apply fix archipelago.service uses ProtectSystem=strict, so /opt and /usr are read-only inside the service's mount namespace. sudo inherits that namespace — every sudo mkdir/mv/chown from apply_update was hitting EROFS even as root. Every prior "Failed to apply update" was a symptom of this. New `host_sudo()` helper wraps every filesystem call in `sudo systemd-run --wait --collect --pipe -- <cmd>`, which spawns a transient unit with systemd's default (no ProtectSystem) protections — the command runs in the host namespace and can touch /opt/archipelago + /usr/local/bin normally. FIPS cascade (#2) Home.vue and Server.vue both carry a FIPS row that previously only looked at {installed, service_active, key_present}. Now they also read anchor_connected + authenticated_peer_count and mirror the full FIPS card: green "Active · N peers" when healthy, orange "No anchor" when the DHT bootstrap has failed. Profile paste URL fallback (#4) Web5Identities.vue list + editor previously had `@error="display:none"` on the <img>, which hid the tag without re-rendering the fallback — a broken pasted URL showed up blank. Replaced with reactive pictureLoadFailed / listPictureFailed flags plus a watcher that resets on URL change. Broken URL now falls back to the initial (or identicon for seed-derived identities). Small-upload data URL (#3) Uploaded profile pictures ≤ 64 KB are now inlined as `data:image/png;base64,...` into profile.picture on the client before calling update-profile. That kind-0 event is fetchable by any Nostr client — no Tor needed. Larger uploads fall back to the onion-rooted public_url with a hint telling the user to paste a public https:// URL for broader visibility. Deferred: #1 FIPS Reconnect "actually fixes" — the current Reconnect calls fips.restart which clears the daemon state, but when the anchor is truly unreachable (UDP 8668 blocked by network/ISP), no amount of restart can help. A richer diagnostic is out of scope for this bundle. Artefacts: archipelago 4a77c704…82aa6f8 40379696 archipelago-frontend-1.7.10-alpha.tar.gz 0644a436…54f58 76983846 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:46:03 -04:00
// Heads-up for large uploads: onion URLs only render on Tor-routed
// clients. Not an error, but worth telling the user.
if (url.includes('.onion/')) {
profileError.value = 'Large image stored on this node. Pasting a public https://… URL is recommended for Nostr visibility.'
}
} 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() {
const hadIdentities = managedIdentities.value.length > 0
identitiesLoading.value = true
try {
const res = await rpcClient.call<{ identities: ManagedIdentity[] }>({ method: 'identity.list' })
managedIdentities.value = res.identities || []
2026-06-12 04:21:18 -04:00
writeIdentitiesCache(managedIdentities.value)
} catch {
if (!hadIdentities) 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>
fix(ui): mesh/web5/apps layout, modal, and search UX fixes - Mesh: fix 920-1280px bottom margin (phantom mobile-nav reservation leaking into the desktop-sidebar range), let the mesh view scale to full width on wide screens instead of capping at 1600px, and make the Device panel collapsible on desktop (previously mobile-only) - Search/controller-nav: a global gamepad/keyboard-nav feature was auto-clicking "the next button in the DOM" on Enter in any text input, which cleared the mesh peer search and popped the sideload modal from the App Store/My Apps search boxes. Opt out via data-controller-no-submit on all filter inputs; bump the mesh clear button's touch target - Modals: several (sideload, credential, Lightning channel open, identity create) used ad-hoc blue buttons and non-fullscreen backdrops that only covered the main content area, not the sidebar. Teleport them to body, unify backdrop/button theming to the dark+orange convention, fix the sideload modal's square bottom corners on desktop, and standardize close buttons to the ghost-icon style - Web5: remove the redundant/dead "Messages" tab from Connected Nodes (its deep-link was unreachable dead code); fix the "view message" toast to actually open the Archipelago channel instead of silently failing to match a LoRa peer; make identity rows responsive via a container query (viewport-based breakpoints don't work in the page's 2-column grid) and right-justify their action icons; collapse DID/DHT/Wallet/Nostr/Connected Nodes by default on mobile - Apps/App Store: match the search bar and sideload button's height, padding, and background to the mode-switcher tabs beside them - Mesh chat: keep the compose input focused after sending Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 18:04:31 -04:00
<style scoped>
/* This card sits in a 2-column page grid, so its own rendered width is
roughly half the viewport a viewport media query can't tell whether
the row actually has room for a horizontal layout. Use a container query
instead, keyed to the row's real width. */
.identities-card {
container-type: inline-size;
container-name: identities-card;
}
@container identities-card (min-width: 560px) {
.identity-row {
flex-direction: row;
align-items: center;
gap: 1rem;
}
.identity-row-main {
flex: 1 1 auto;
}
}
</style>