From 35bb1bc576fa334a2c497d3c102de4f420dc6dc7 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Jun 2026 09:35:40 -0400 Subject: [PATCH] [ui] #36: auto-open the unlock-saved-key flow from storage when an nsec session is locked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the locked-nsec detection: clicking "Unlock Storage" on a locked persisted-nsec session (no in-memory signer after a page reload) now auto-opens the login modal straight to its existing unlock-saved-key (passphrase) flow, so the user re-unlocks in one step instead of hunting for the Login menu. Once unlocked, the signer is active app-wide and re-opening the storage pane auto-unlocks. - applicationBootstrap: add a side-effect-free openLoginModal service to profileModalServices (app.loginModalController.openModal — no add-account callback; idempotently ensures the modal is initialized first). - ProfileStorageController: reportLockedNsecSession({ autoOpenLogin }) opens the modal only from the user-initiated unlock click, never from the passive pane render (no surprise popups). - Tests extended to 8 (auto-open fires on click; stays closed on passive render). Build + lint clean. Co-Authored-By: Claude Opus 4.8 --- js/ui/applicationBootstrap.js | 27 +++++++++++++++++++ .../profileModal/ProfileStorageController.js | 25 ++++++++++++++--- tests/storage-unlock-locked-nsec.test.mjs | 19 ++++++++++++- todo/TODO_2026-06-20_pre-launch.md | 15 +++++++---- 4 files changed, 77 insertions(+), 9 deletions(-) diff --git a/js/ui/applicationBootstrap.js b/js/ui/applicationBootstrap.js index 3e69eebf..6a8ec5bd 100644 --- a/js/ui/applicationBootstrap.js +++ b/js/ui/applicationBootstrap.js @@ -543,6 +543,33 @@ export default class ApplicationBootstrap { authService: app.authService, requestAddProfileLogin: (options) => app.requestProfileAdditionLogin(options), + // Side-effect-free open of the login modal (no add-account callback). Used by + // the storage pane to route a locked persisted-nsec session to the existing + // unlock-saved-key (passphrase) flow. Returns true if the modal opened. + openLoginModal: (options = {}) => { + try { + if (typeof app.initializeLoginModalController === "function") { + app.initializeLoginModalController(); + } + } catch (error) { + devLogger.warn( + "[Application] Failed to ensure login modal before open:", + error, + ); + } + const controller = app.loginModalController; + if (controller && typeof controller.openModal === "function") { + try { + return controller.openModal(options) !== false; + } catch (error) { + devLogger.warn( + "[Application] Failed to open login modal:", + error, + ); + } + } + return false; + }, describeLoginError: (error, fallbackMessage) => app.describeLoginError(error, fallbackMessage), hashtagPreferences: app.hashtagPreferences, diff --git a/js/ui/profileModal/ProfileStorageController.js b/js/ui/profileModal/ProfileStorageController.js index 0b49185b..4246cf40 100644 --- a/js/ui/profileModal/ProfileStorageController.js +++ b/js/ui/profileModal/ProfileStorageController.js @@ -517,7 +517,7 @@ export class ProfileStorageController { return meta; } - reportLockedNsecSession() { + reportLockedNsecSession({ autoOpenLogin = false } = {}) { userLogger.info( "[storage-unlock] saved nsec key is locked (no in-memory signer after reload); prompting re-unlock.", ); @@ -527,6 +527,25 @@ export class ProfileStorageController { error.code = "storage-unlock-locked-nsec-session"; this.setStorageUnlockFailureState(error); this.mainController.showError(this.getStorageUnlockFailureMessage(error)); + + // When the user actively clicked "Unlock Storage", open the login modal straight + // to its existing unlock-saved-key (passphrase) flow so they can re-unlock in one + // step. After they unlock, the signer is active app-wide and re-opening the storage + // pane auto-unlocks. We don't auto-open from the passive pane render (would be + // surprising); the status text + button still guide them there. + if (autoOpenLogin) { + const openLoginModal = this.mainController.services?.openLoginModal; + if (typeof openLoginModal === "function") { + try { + openLoginModal(); + } catch (openError) { + devLogger.warn( + "[ProfileModal] Failed to open login modal for saved-key unlock:", + openError, + ); + } + } + } } async requestStorageUnlockPermissions() { @@ -570,7 +589,7 @@ export class ProfileStorageController { if (!signer) { if (this.getLockedStoredNsecSession(pubkey)) { - this.reportLockedNsecSession(); + this.reportLockedNsecSession({ autoOpenLogin: true }); return; } this.mainController.showError("No active signer found. Please login."); @@ -647,7 +666,7 @@ export class ProfileStorageController { // A restored-but-locked persisted nsec session can leave a decrypt-less stub // signer; route it to the re-unlock guidance rather than the generic message. if (this.getLockedStoredNsecSession(pubkey)) { - this.reportLockedNsecSession(); + this.reportLockedNsecSession({ autoOpenLogin: true }); return; } const missingDecryptError = new Error( diff --git a/tests/storage-unlock-locked-nsec.test.mjs b/tests/storage-unlock-locked-nsec.test.mjs index c085df43..be7240bf 100644 --- a/tests/storage-unlock-locked-nsec.test.mjs +++ b/tests/storage-unlock-locked-nsec.test.mjs @@ -20,6 +20,7 @@ const OTHER_PUBKEY = "b".repeat(64); function makeController({ meta } = {}) { const shownErrors = []; + const loginOpens = []; const mainController = { normalizeHexPubkey: (value) => typeof value === "string" ? value.trim().toLowerCase() : "", @@ -29,10 +30,14 @@ function makeController({ meta } = {}) { nostrClient: { getStoredSessionActorMetadata: () => meta, }, + openLoginModal: (options) => { + loginOpens.push(options ?? null); + return true; + }, }, }; const controller = new ProfileStorageController(mainController); - return { controller, shownErrors }; + return { controller, shownErrors, loginOpens }; } const persistedNsecMeta = { @@ -81,6 +86,18 @@ test("reporting the locked session shows the actionable re-unlock message, not a assert.doesNotMatch(msg, /no active signer/i); }); +test("autoOpenLogin opens the login modal's unlock flow (one-click re-unlock)", () => { + const { controller, loginOpens } = makeController({ meta: persistedNsecMeta }); + controller.reportLockedNsecSession({ autoOpenLogin: true }); + assert.equal(loginOpens.length, 1, "should open the login modal exactly once"); +}); + +test("the passive path does NOT auto-open the login modal (no surprise popups)", () => { + const { controller, loginOpens } = makeController({ meta: persistedNsecMeta }); + controller.reportLockedNsecSession(); + assert.equal(loginOpens.length, 0, "default (passive) report must not open a modal"); +}); + test("the new error code maps to a saved-key/passphrase message", () => { const { controller } = makeController({ meta: persistedNsecMeta }); const msg = controller.getStorageUnlockFailureMessage({ diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index 64391754..b908e88d 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -814,11 +814,16 @@ Reported 2026-06-25. Relates to #17 (NIP-71 interop) / the bitvid→NIP-71 mirro no-signer / no-decryptor messages. New error code `storage-unlock-locked-nsec-session`. Tests: `tests/storage-unlock-locked-nsec.test.mjs` (6, behavioral). Build + lint clean. -- [ ] **Follow-up (UX nicety):** auto-open the login modal's existing unlock-saved-key - (passphrase) flow directly from the storage pane so the user re-unlocks in one - click instead of navigating to Login — needs a side-effect-free - `requestUnlockStoredSession` hook (the current `requestAddProfileLogin` path runs an - add-account callback). Deferred to keep this fix small. +- [x] **Follow-up (UX nicety) — DONE 2026-06-30.** Clicking "Unlock Storage" on a + locked persisted-nsec session now **auto-opens the login modal** straight to its + existing unlock-saved-key (passphrase) flow, so the user re-unlocks in one step. + Added a side-effect-free `openLoginModal` service to `profileModalServices` + (`app.loginModalController.openModal` — no add-account callback, idempotently + ensures the modal exists first); `reportLockedNsecSession({ autoOpenLogin })` calls + it only from the user-initiated unlock click, NOT the passive pane render (no + surprise popups). After the user unlocks, the signer is active app-wide and + re-opening the storage pane auto-unlocks. Tests extended (8 total: asserts auto-open + fires on click, stays closed on passive render). - [ ] **VERIFY on unstable:** nsec-login with "remember key" + passphrase, reload the 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