From f00d2b5f4920003b277493daa5384e30508c5465 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:24:29 -0400 Subject: [PATCH] [ui] upload modal: prompt for the nsec passphrase instead of dead-ending after reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With a persisted ("remember this key") nsec login, uploading a video after a page reload reported "storage is locked" and then "No signer available to unlock storage." The reload restores the logged-in pubkey but NOT the in-memory signer (the key is passphrase-encrypted), and UploadModal.handleUnlock dead-ended on the alert — it never asked for the passphrase. Fix (mirrors the storage pane's #36 behavior): handleUnlock now calls promptStoredNsecUnlock(pubkey) when there's no usable signer. If the active account has a locked saved nsec key (getStoredSessionActorMetadata), it shows an actionable message and opens the login modal's existing unlock-saved-key (passphrase) flow via a new onRequestUnlock hook (wired in initUploadModal -> app.loginModalController.openModal), instead of the dead-end alert. After the user re-enters their passphrase the signer is active and the upload proceeds. Tests: tests/upload-modal-nsec-unlock.test.mjs (5 — detects locked nsec for the active account + opens unlock; ignores missing/non-nsec/other-account; falls through with no hook). Build + lint clean. Co-Authored-By: Claude Opus 4.8 --- js/ui/components/UploadModal.js | 48 +++++++++++++++++ js/ui/initUploadModal.js | 16 ++++++ tests/upload-modal-nsec-unlock.test.mjs | 71 +++++++++++++++++++++++++ todo/TODO_2026-06-20_pre-launch.md | 9 ++++ 4 files changed, 144 insertions(+) create mode 100644 tests/upload-modal-nsec-unlock.test.mjs diff --git a/js/ui/components/UploadModal.js b/js/ui/components/UploadModal.js index 2f6b0d90..3e0910e3 100644 --- a/js/ui/components/UploadModal.js +++ b/js/ui/components/UploadModal.js @@ -53,6 +53,7 @@ export class UploadModal { eventTarget, container, onRequestStorageSettings, + onRequestUnlock, } = {}) { this.authService = authService || null; this.r2Service = r2Service || null; @@ -88,6 +89,9 @@ export class UploadModal { eventTarget instanceof EventTarget ? eventTarget : new EventTarget(); this.container = container || null; this.onRequestStorageSettings = typeof onRequestStorageSettings === "function" ? onRequestStorageSettings : null; + // Opens the login modal's unlock-saved-key (passphrase) flow — used when a + // persisted nsec session is locked after a reload. + this.onRequestUnlock = typeof onRequestUnlock === "function" ? onRequestUnlock : null; this.root = null; this.isVisible = false; @@ -813,6 +817,13 @@ export class UploadModal { ? signer.canSign() : typeof signer?.signEvent === "function"; if (!canSign) { + // A persisted ("remember this key") nsec session restores the logged-in + // pubkey after a reload but NOT the in-memory signer (the key is passphrase- + // encrypted). Prompt the user to re-unlock their saved key instead of dead- + // ending — matches the storage pane's behavior (#36). + if (this.promptStoredNsecUnlock(pubkey)) { + return; + } alert("No signer available to unlock storage."); return; } @@ -844,6 +855,43 @@ export class UploadModal { } } + // Returns true if a locked persisted-nsec session was detected (for the active + // account) and the re-unlock (passphrase) flow was opened, so the caller can stop. + promptStoredNsecUnlock(pubkey) { + const client = this.authService?.nostrClient; + if (!client || typeof client.getStoredSessionActorMetadata !== "function") { + return false; + } + let meta = null; + try { + meta = client.getStoredSessionActorMetadata(); + } catch (err) { + return false; + } + if (!meta || meta.hasEncryptedKey !== true || meta.source !== "nsec") { + return false; + } + const metaPubkey = + typeof meta.pubkey === "string" ? meta.pubkey.trim().toLowerCase() : ""; + const activePubkey = + typeof pubkey === "string" ? pubkey.trim().toLowerCase() : ""; + if (activePubkey && metaPubkey && metaPubkey !== activePubkey) { + return false; + } + if (typeof this.onRequestUnlock !== "function") { + return false; + } + this.showError( + "Your saved key is locked after reloading. Re-enter your passphrase to unlock it, then try the upload again.", + ); + try { + this.onRequestUnlock(); + } catch (err) { + userLogger.warn("[UploadModal] Failed to open saved-key unlock flow:", err); + } + return true; + } + updateLockUi() { const locked = !this.isStorageUnlocked; if (this.statusText?.storageLock) { diff --git a/js/ui/initUploadModal.js b/js/ui/initUploadModal.js index 466d48dc..7309f17f 100644 --- a/js/ui/initUploadModal.js +++ b/js/ui/initUploadModal.js @@ -55,6 +55,22 @@ export function initUploadModal({ app.profileController.show("storage"); } }, + // Open the login modal's unlock-saved-key (passphrase) flow when a persisted + // nsec session is locked after a reload. + onRequestUnlock: () => { + try { + if (typeof app?.initializeLoginModalController === "function") { + app.initializeLoginModalController(); + } + const controller = app?.loginModalController; + if (controller && typeof controller.openModal === "function") { + return controller.openModal({}) !== false; + } + } catch (error) { + /* best-effort */ + } + return false; + }, }); const boundSubmitHandler = diff --git a/tests/upload-modal-nsec-unlock.test.mjs b/tests/upload-modal-nsec-unlock.test.mjs new file mode 100644 index 00000000..e0cb048a --- /dev/null +++ b/tests/upload-modal-nsec-unlock.test.mjs @@ -0,0 +1,71 @@ +// Bug: with a persisted nsec login, uploading after a page reload said "storage is +// locked" and then "No signer available to unlock storage." — the persisted nsec +// signer isn't in memory after reload (the key is passphrase-encrypted), and the +// upload modal dead-ended instead of prompting for the passphrase. +// +// promptStoredNsecUnlock() detects that exact case (a saved nsec key for the active +// account) and opens the re-unlock flow, returning true so handleUnlock stops before +// the dead-end alert. Tested via the prototype to avoid constructing the full modal. + +import test from "node:test"; +import { strict as assert } from "node:assert"; +import { UploadModal } from "../js/ui/components/UploadModal.js"; + +const PUBKEY = "a".repeat(64); +const OTHER = "b".repeat(64); +const prompt = UploadModal.prototype.promptStoredNsecUnlock; + +function makeCtx({ meta, withUnlock = true } = {}) { + const calls = { unlock: 0, errors: [] }; + return { + ctx: { + authService: { + nostrClient: { getStoredSessionActorMetadata: () => meta }, + }, + onRequestUnlock: withUnlock + ? () => { + calls.unlock += 1; + return true; + } + : null, + showError: (m) => calls.errors.push(m), + }, + calls, + }; +} + +const lockedNsec = { hasEncryptedKey: true, pubkey: PUBKEY, source: "nsec" }; + +test("detects a locked saved nsec key for the active account and opens the unlock flow", () => { + const { ctx, calls } = makeCtx({ meta: lockedNsec }); + const handled = prompt.call(ctx, PUBKEY); + assert.equal(handled, true, "returns true so handleUnlock stops before the dead-end"); + assert.equal(calls.unlock, 1, "opened the re-unlock (passphrase) flow"); + assert.equal(calls.errors.length, 1); + assert.match(calls.errors[0], /passphrase/i); +}); + +test("returns false when there is no saved session (real missing-signer case)", () => { + const { ctx } = makeCtx({ meta: null }); + assert.equal(prompt.call(ctx, PUBKEY), false); +}); + +test("returns false for a non-nsec stored session", () => { + const { ctx } = makeCtx({ + meta: { hasEncryptedKey: true, pubkey: PUBKEY, source: "extension" }, + }); + assert.equal(prompt.call(ctx, PUBKEY), false); +}); + +test("does not hijack when the saved key is for a DIFFERENT account", () => { + const { ctx, calls } = makeCtx({ + meta: { hasEncryptedKey: true, pubkey: OTHER, source: "nsec" }, + }); + assert.equal(prompt.call(ctx, PUBKEY), false); + assert.equal(calls.unlock, 0); +}); + +test("returns false (falls through to the alert) when no unlock hook is wired", () => { + const { ctx } = makeCtx({ meta: lockedNsec, withUnlock: false }); + assert.equal(prompt.call(ctx, PUBKEY), false); +}); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index e6bee93f..ba2e4a04 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -855,6 +855,15 @@ Reported 2026-06-25. Relates to #17 (NIP-71 interop) / the bitvid→NIP-71 mirro page, open Profile → Storage → the status should read the actionable re-unlock hint (not "No active signer"); after re-entering the passphrase via Login, storage unlocks normally. (`[storage-unlock]` console line confirms the locked-key branch.) +- [x] **Same fix for the UPLOAD modal — DONE 2026-06-30.** The Upload modal's "Unlock + storage" had its OWN unlock path (`UploadModal.handleUnlock`) that dead-ended on a + reloaded persisted-nsec session with "No signer available to unlock storage." + Added `promptStoredNsecUnlock(pubkey)`: when the active account has a locked saved + nsec key (`getStoredSessionActorMetadata`), it shows an actionable message and opens + the login modal's unlock-saved-key (passphrase) flow (`onRequestUnlock`, wired in + `initUploadModal` → `app.loginModalController.openModal`) instead of the alert. After + the user re-enters their passphrase the signer is active and the upload proceeds. + Tests: `tests/upload-modal-nsec-unlock.test.mjs` (5). Build + lint clean. ### 37. Channel playlists — creator-curated custom note lists - [ ] Let creators build **playlists** (custom curated lists of their videos / notes)