diff --git a/js/ui/components/EditModal.js b/js/ui/components/EditModal.js index 51fdd842..c441a00a 100644 --- a/js/ui/components/EditModal.js +++ b/js/ui/components/EditModal.js @@ -25,6 +25,7 @@ export class EditModal { storageService = null, getCurrentPubkey = null, safeEncodeNpub = null, + ensureSigner = null, eventTarget, container, } = {}) { @@ -39,6 +40,9 @@ export class EditModal { this.storageService = storageService || null; this.getCurrentPubkey = typeof getCurrentPubkey === "function" ? getCurrentPubkey : null; + // Shared signer gate (#56): lets the "Replace file" pickers unlock storage + // inline (cached keep-unlocked key or one passphrase prompt). + this.ensureSigner = typeof ensureSigner === "function" ? ensureSigner : null; this.mediaUploader = new MediaUploader({ r2Service, s3Service, diff --git a/js/ui/components/UploadModal.js b/js/ui/components/UploadModal.js index 05787993..28d45373 100644 --- a/js/ui/components/UploadModal.js +++ b/js/ui/components/UploadModal.js @@ -27,6 +27,11 @@ import { } from "../../nostrClientFacade.js"; import { UI_FEEDBACK_DELAY_MS } from "../../constants.js"; import { showConfirm } from "../confirmDialog.js"; +import { + ensureStorageUnlockedForUpload, + resetThumbnailPicker, + promptStoredNsecUnlock, +} from "./uploadModalStorageUnlock.js"; const INFO_HASH_PATTERN = /^[a-f0-9]{40}$/; @@ -55,6 +60,7 @@ export class UploadModal { container, onRequestStorageSettings, onRequestUnlock, + ensureSigner, getHashtagSuggestions, } = {}) { this.authService = authService || null; @@ -96,6 +102,10 @@ export class UploadModal { // 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; + // Shared signer gate (app.ensureEncryptionCapableSigner): silently restores a + // kept-unlocked key or shows ONE passphrase prompt, so the file pickers can + // unlock storage inline instead of dead-ending (#56). + this.ensureSigner = typeof ensureSigner === "function" ? ensureSigner : null; this.root = null; this.isVisible = false; @@ -530,9 +540,13 @@ export class UploadModal { return; } if (!this.isStorageUnlocked) { - this.showError("Please unlock storage before selecting a file."); - e.target.value = ""; - return; + // #56: unlock inline (silent restore or one passphrase prompt) and + // continue with this same file pick instead of erroring. + const unlocked = await this.ensureStorageUnlockedForUpload(); + if (!unlocked) { + e.target.value = ""; + return; + } } // Start new session @@ -628,12 +642,19 @@ export class UploadModal { } async handleThumbnailSelection(file) { - if (!this.storageConfigured || !this.isStorageUnlocked) { - // We can't upload yet. Just hold it in state or warn? - // Since UI disables the browse button until unlocked (mostly), we assume safe. - // But if they just unlocked, we're good. + if (!this.storageConfigured) { + this.showError("Configure storage before uploading a thumbnail."); + this.resetThumbnailPicker(); return; } + if (!this.isStorageUnlocked) { + // #56: prompt to unlock inline instead of silently dropping the pick. + const unlocked = await this.ensureStorageUnlockedForUpload(); + if (!unlocked) { + this.resetThumbnailPicker(); + return; + } + } const currentUploadId = ++this.thumbnailUploadId; @@ -1011,41 +1032,18 @@ 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. + // #36 / #56 storage-unlock glue lives in uploadModalStorageUnlock.js (file-size + // budget); these stay as instance methods so callers/tests are unchanged. + async ensureStorageUnlockedForUpload() { + return ensureStorageUnlockedForUpload(this); + } + + resetThumbnailPicker() { + resetThumbnailPicker(this); + } + 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; + return promptStoredNsecUnlock(this, pubkey); } updateLockUi() { diff --git a/js/ui/components/editModalUpload.js b/js/ui/components/editModalUpload.js index 5317e731..d75a3ca2 100644 --- a/js/ui/components/editModalUpload.js +++ b/js/ui/components/editModalUpload.js @@ -7,6 +7,10 @@ // and its rendered root (`context`). import { devLogger, userLogger } from "../../utils/logger.js"; +import { + getActiveSigner, + requestDefaultExtensionPermissions, +} from "../../nostrClientFacade.js"; // Set a field's value AND mark it edited/unlocked so EditModal.collect treats it // as a user change (isEditing() reads readOnly===false || dataset.isEditing). @@ -24,7 +28,8 @@ function setStatus(el, text) { // Resolve the active storage connection and surface a user-facing reason when // upload isn't possible. Returns the connection on success, null otherwise. -async function ensureUploadable(modal) { +// `deps` are injectable for tests only. +export async function ensureUploadable(modal, deps = {}) { if (!modal.mediaUploader) { modal.showError("Uploads are unavailable in this view."); return null; @@ -40,7 +45,88 @@ async function ensureUploadable(modal) { return null; } if (!conn.unlocked || !conn.credentials) { - modal.showError("Unlock your storage in the profile modal, then try again."); + // #56: unlock inline (kept-unlocked restore or one passphrase prompt) and + // continue with this same file pick instead of bouncing the user to the + // profile modal. + return tryInlineStorageUnlock(modal, deps); + } + return conn; +} + +const UNLOCK_FALLBACK_MESSAGE = + "Unlock your storage in the profile modal, then try again."; + +async function tryInlineStorageUnlock(modal, deps = {}) { + const getSigner = + typeof deps.getSigner === "function" ? deps.getSigner : getActiveSigner; + const requestPermissions = + typeof deps.requestPermissions === "function" + ? deps.requestPermissions + : requestDefaultExtensionPermissions; + + const pubkey = + typeof modal.getCurrentPubkey === "function" ? modal.getCurrentPubkey() : null; + if (!pubkey || !modal.storageService) { + modal.showError(UNLOCK_FALLBACK_MESSAGE); + return null; + } + + if (typeof modal.ensureSigner === "function") { + let gate = null; + try { + gate = await modal.ensureSigner({ + pubkey, + need: "encrypt", + promptMessage: + "Re-enter your PIN / passphrase to unlock storage for this upload.", + }); + } catch (error) { + devLogger.warn("[editModalUpload] Signer gate failed:", error); + } + if (gate && gate.ok !== true) { + // Cancel aborts quietly; a bad passphrase already showed its own toast. + if (gate.reason !== "cancelled" && gate.reason !== "bad-passphrase") { + modal.showError(UNLOCK_FALLBACK_MESSAGE); + } + return null; + } + } + + const signer = getSigner(); + if ( + !signer || + (typeof signer.nip44Decrypt !== "function" && + typeof signer.nip04Decrypt !== "function") + ) { + modal.showError(UNLOCK_FALLBACK_MESSAGE); + return null; + } + + try { + if (signer.type === "extension" || signer.type === "nip07") { + const permissionResult = await requestPermissions(); + if (!permissionResult?.ok) { + modal.showError("Extension permissions are required to unlock storage."); + return null; + } + } + await modal.storageService.unlock(pubkey, { signer }); + } catch (error) { + userLogger.error("[editModalUpload] Storage unlock failed:", error); + modal.showError( + "Failed to unlock storage: " + (error?.message || String(error)), + ); + return null; + } + + let conn = null; + try { + conn = await modal.mediaUploader.resolveActiveConnection(); + } catch (error) { + devLogger.warn("[editModalUpload] Failed to re-resolve storage connection:", error); + } + if (!conn?.unlocked || !conn?.credentials) { + modal.showError(UNLOCK_FALLBACK_MESSAGE); return null; } return conn; diff --git a/js/ui/components/uploadModalStorageUnlock.js b/js/ui/components/uploadModalStorageUnlock.js new file mode 100644 index 00000000..f5b4a1f7 --- /dev/null +++ b/js/ui/components/uploadModalStorageUnlock.js @@ -0,0 +1,106 @@ +// js/ui/components/uploadModalStorageUnlock.js +// +// Storage-unlock glue for the Upload modal (#36 / #56), kept out of +// UploadModal.js to stay within its file-size budget. Every function operates +// on the UploadModal instance (`modal`); UploadModal exposes them as thin +// prototype delegates so callers and tests keep using the instance methods. + +import { devLogger, userLogger } from "../../utils/logger.js"; + +// #56: the file pickers unlock storage inline instead of dead-ending. The +// shared signer gate (app.ensureEncryptionCapableSigner) restores a cached +// keep-unlocked key silently or shows ONE passphrase prompt; handleUnlock +// then unlocks storage with the restored signer (extension permissions + +// credential reload included). Returns true when the upload can proceed. +// Cancel / bad-passphrase abort quietly (the gate surfaces its own toast for +// a wrong passphrase); other gate failures fall through to handleUnlock so +// its existing messaging (saved-key flow / "no signer") still applies. +export async function ensureStorageUnlockedForUpload(modal) { + if (!modal.storageService) return false; + const pubkey = modal.getCurrentPubkey ? modal.getCurrentPubkey() : null; + if (!pubkey) return false; + + if (!modal.storageService.isUnlocked(pubkey)) { + if (typeof modal.ensureSigner === "function") { + let gate = null; + try { + gate = await modal.ensureSigner({ + pubkey, + need: "encrypt", + promptMessage: + "Re-enter your PIN / passphrase to unlock storage for this upload.", + }); + } catch (error) { + devLogger?.warn?.("[UploadModal] Signer gate failed:", error); + } + if ( + gate && + gate.ok !== true && + (gate.reason === "cancelled" || gate.reason === "bad-passphrase") + ) { + return false; + } + } + await modal.handleUnlock(); + } + + const unlocked = modal.storageService.isUnlocked(pubkey); + if (unlocked && (!modal.isStorageUnlocked || !modal.activeCredentials)) { + modal.isStorageUnlocked = true; + modal.updateLockUi(); + await modal.loadFromStorage(); + } + modal.isStorageUnlocked = unlocked; + return unlocked; +} + +// Undo the picker-side effects of a thumbnail selection that couldn't upload +// (placeholder shows the picked filename and the URL input is disabled). +export function resetThumbnailPicker(modal) { + if (modal.inputs?.thumbnailFile) { + modal.inputs.thumbnailFile.value = ""; + } + const thumbnail = modal.inputs?.thumbnail; + if (thumbnail) { + thumbnail.placeholder = "https://example.com/thumbnail.jpg"; + thumbnail.disabled = false; + } +} + +// 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 instead of dead-ending with "No signer available". +export function promptStoredNsecUnlock(modal, pubkey) { + const client = modal.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 modal.onRequestUnlock !== "function") { + return false; + } + modal.showError( + "Your saved key is locked after reloading. Re-enter your passphrase to unlock it, then try the upload again.", + ); + try { + modal.onRequestUnlock(); + } catch (err) { + userLogger.warn("[UploadModal] Failed to open saved-key unlock flow:", err); + } + return true; +} diff --git a/js/ui/initEditModal.js b/js/ui/initEditModal.js index 2f4cc9bb..78244c7d 100644 --- a/js/ui/initEditModal.js +++ b/js/ui/initEditModal.js @@ -51,6 +51,12 @@ export function initEditModal({ storageService, getCurrentPubkey, safeEncodeNpub, + // Shared signer gate (#56): lets the "Replace file" pickers unlock storage + // inline (cached keep-unlocked key or one passphrase prompt). + ensureSigner: (options) => + typeof app?.ensureEncryptionCapableSigner === "function" + ? app.ensureEncryptionCapableSigner(options) + : null, eventTarget: events, container, }); diff --git a/js/ui/initUploadModal.js b/js/ui/initUploadModal.js index 06ccc9e8..c70e9d6d 100644 --- a/js/ui/initUploadModal.js +++ b/js/ui/initUploadModal.js @@ -61,6 +61,12 @@ export function initUploadModal({ app.profileController.show("storage"); } }, + // Shared signer gate (#56): restores a kept-unlocked key silently or shows + // one passphrase prompt so the file pickers can unlock storage inline. + ensureSigner: (options) => + typeof app?.ensureEncryptionCapableSigner === "function" + ? app.ensureEncryptionCapableSigner(options) + : null, // Open the login modal's unlock-saved-key (passphrase) flow when a persisted // nsec session is locked after a reload. onRequestUnlock: () => { diff --git a/tests/upload-edit-storage-unlock-gate.test.mjs b/tests/upload-edit-storage-unlock-gate.test.mjs new file mode 100644 index 00000000..a90912e8 --- /dev/null +++ b/tests/upload-edit-storage-unlock-gate.test.mjs @@ -0,0 +1,247 @@ +// #56: when storage is locked, the upload modal's file pickers and the edit +// modal's "Replace file" pickers must unlock inline — silent kept-unlocked +// restore or ONE passphrase prompt via the shared signer gate — and continue +// with the same file pick, instead of dead-ending ("Please unlock storage…", +// a silent no-op, or "Unlock your storage in the profile modal"). +// +// test_integrity_note: +// change_type: ["new_tests"] +// scenarios: +// - id: SCN-upload-picker-inline-unlock +// given: "storage locked for the active pubkey; a signer gate that succeeds / is cancelled / fails" +// when: "ensureStorageUnlockedForUpload / handleThumbnailSelection run" +// then: "gate ok -> storage unlocked and pick proceeds; cancel/bad-passphrase -> quiet abort; other failures fall through to the regular unlock messaging" +// - id: SCN-edit-replace-file-inline-unlock +// given: "the edit modal's resolved storage connection is locked" +// when: "ensureUploadable runs" +// then: "the gate + storageService.unlock run and the re-resolved unlocked connection is returned; cancel aborts without unlocking" +// observable_outcomes: +// - "storageService.unlock called exactly when the gate allows it" +// - "cancelled gate -> no unlock attempt, no error toast" +// - "thumbnail picker state reset when the unlock is refused" +// determinism_controls: +// - "pure fake ctx objects; no DOM, no timers, no network" +// anti_cheat_rationale: +// prevents: ["hard-coded return value", "over-mocking internal logic"] +// relaxation: +// did_relax_any_assertion: false + +import test from "node:test"; +import { strict as assert } from "node:assert"; +import { UploadModal } from "../js/ui/components/UploadModal.js"; +import { ensureUploadable } from "../js/ui/components/editModalUpload.js"; + +const PUBKEY = "a".repeat(64); +const ensureUnlocked = UploadModal.prototype.ensureStorageUnlockedForUpload; +const handleThumb = UploadModal.prototype.handleThumbnailSelection; + +// --- UploadModal.ensureStorageUnlockedForUpload --- + +function makeUploadCtx({ unlockedAfterHandleUnlock = true, gate } = {}) { + const calls = { handleUnlock: 0, loadFromStorage: 0, errors: [], gates: [] }; + let unlocked = false; + const ctx = { + getCurrentPubkey: () => PUBKEY, + storageService: { isUnlocked: () => unlocked }, + ensureSigner: + gate === undefined + ? null + : async (options) => { + calls.gates.push(options); + return gate; + }, + handleUnlock: async () => { + calls.handleUnlock += 1; + if (unlockedAfterHandleUnlock) unlocked = true; + }, + updateLockUi: () => {}, + loadFromStorage: async () => { + calls.loadFromStorage += 1; + ctx.activeCredentials = { accessKeyId: "k" }; + }, + showError: (m) => calls.errors.push(m), + isStorageUnlocked: false, + activeCredentials: null, + }; + return { ctx, calls, setUnlocked: (v) => (unlocked = v) }; +} + +test("upload picker: gate ok -> unlocks via handleUnlock and proceeds", async () => { + const { ctx, calls } = makeUploadCtx({ gate: { ok: true } }); + const result = await ensureUnlocked.call(ctx); + assert.equal(result, true); + assert.equal(calls.gates.length, 1, "signer gate consulted once"); + assert.equal(calls.gates[0].need, "encrypt"); + assert.equal(calls.gates[0].pubkey, PUBKEY); + assert.equal(calls.handleUnlock, 1, "storage unlock ran after the gate"); + assert.equal(ctx.isStorageUnlocked, true); + assert.equal(calls.errors.length, 0); +}); + +test("upload picker: cancelled gate aborts quietly (no unlock, no toast)", async () => { + const { ctx, calls } = makeUploadCtx({ + gate: { ok: false, reason: "cancelled" }, + }); + assert.equal(await ensureUnlocked.call(ctx), false); + assert.equal(calls.handleUnlock, 0, "no unlock attempt after cancel"); + assert.equal(calls.errors.length, 0, "cancel is silent"); + assert.equal(ctx.isStorageUnlocked, false); +}); + +test("upload picker: bad passphrase aborts without a second toast", async () => { + const { ctx, calls } = makeUploadCtx({ + gate: { ok: false, reason: "bad-passphrase" }, + }); + assert.equal(await ensureUnlocked.call(ctx), false); + assert.equal(calls.handleUnlock, 0); + assert.equal(calls.errors.length, 0, "the gate already showed its own toast"); +}); + +test("upload picker: non-user gate failures fall through to handleUnlock's messaging", async () => { + const { ctx, calls } = makeUploadCtx({ + gate: { ok: false, reason: "no-stored-key" }, + unlockedAfterHandleUnlock: false, + }); + assert.equal(await ensureUnlocked.call(ctx), false); + assert.equal(calls.handleUnlock, 1, "regular unlock path still runs"); +}); + +test("upload picker: already-unlocked storage loads credentials without any gate", async () => { + const { ctx, calls, setUnlocked } = makeUploadCtx({ gate: { ok: true } }); + setUnlocked(true); + assert.equal(await ensureUnlocked.call(ctx), true); + assert.equal(calls.gates.length, 0, "no prompt when storage is already unlocked"); + assert.equal(calls.handleUnlock, 0); + assert.equal(calls.loadFromStorage, 1, "stale credentials refreshed"); + assert.ok(ctx.activeCredentials); +}); + +test("thumbnail pick while locked: refused unlock resets the picker, no upload", async () => { + const calls = { reset: 0, uploads: 0 }; + const ctx = { + storageConfigured: true, + isStorageUnlocked: false, + ensureStorageUnlockedForUpload: async () => false, + resetThumbnailPicker: () => { + calls.reset += 1; + }, + mediaUploader: { + uploadThumbnail: async () => { + calls.uploads += 1; + return { url: "https://x/thumb.jpg" }; + }, + }, + showError: () => {}, + }; + await handleThumb.call(ctx, { name: "t.jpg" }); + assert.equal(calls.reset, 1, "picker state restored"); + assert.equal(calls.uploads, 0, "upload never started"); +}); + +// --- editModalUpload.ensureUploadable (Edit modal "Replace file") --- + +function makeEditCtx({ gate, unlockThrows = false } = {}) { + const calls = { unlock: [], errors: [], gates: [], resolves: 0 }; + let unlocked = false; + const conn = () => ({ + configured: true, + unlocked, + credentials: unlocked ? { accessKeyId: "k" } : null, + provider: "s3", + }); + const modal = { + mediaUploader: { + resolveActiveConnection: async () => { + calls.resolves += 1; + return conn(); + }, + }, + storageService: { + unlock: async (pubkey, opts) => { + calls.unlock.push({ pubkey, signer: opts?.signer }); + if (unlockThrows) throw new Error("boom"); + unlocked = true; + }, + }, + getCurrentPubkey: () => PUBKEY, + ensureSigner: + gate === undefined + ? null + : async (options) => { + calls.gates.push(options); + return gate; + }, + showError: (m) => calls.errors.push(m), + }; + return { modal, calls }; +} + +const capableSigner = { nip44Decrypt: () => "x" }; + +test("edit replace-file: locked connection unlocks inline and returns the live connection", async () => { + const { modal, calls } = makeEditCtx({ gate: { ok: true } }); + const conn = await ensureUploadable(modal, { getSigner: () => capableSigner }); + assert.ok(conn, "connection returned"); + assert.equal(conn.unlocked, true); + assert.ok(conn.credentials, "credentials present after inline unlock"); + assert.equal(calls.gates.length, 1, "signer gate consulted"); + assert.deepEqual(calls.unlock[0].pubkey, PUBKEY); + assert.equal(calls.errors.length, 0); + assert.equal(calls.resolves, 2, "connection re-resolved after the unlock"); +}); + +test("edit replace-file: cancelled gate aborts without touching storage", async () => { + const { modal, calls } = makeEditCtx({ + gate: { ok: false, reason: "cancelled" }, + }); + const conn = await ensureUploadable(modal, { getSigner: () => capableSigner }); + assert.equal(conn, null); + assert.equal(calls.unlock.length, 0, "no unlock attempt after cancel"); + assert.equal(calls.errors.length, 0, "cancel is silent"); +}); + +test("edit replace-file: no gate + no capable signer keeps the actionable profile-modal message", async () => { + const { modal, calls } = makeEditCtx({}); + const conn = await ensureUploadable(modal, { getSigner: () => null }); + assert.equal(conn, null); + assert.equal(calls.unlock.length, 0); + assert.equal(calls.errors.length, 1); + assert.match(calls.errors[0], /profile modal/i); +}); + +test("edit replace-file: extension signer requests permissions before unlocking", async () => { + const { modal, calls } = makeEditCtx({ gate: { ok: true } }); + let permissionAsks = 0; + const conn = await ensureUploadable(modal, { + getSigner: () => ({ type: "extension", nip04Decrypt: () => "x" }), + requestPermissions: async () => { + permissionAsks += 1; + return { ok: true }; + }, + }); + assert.ok(conn); + assert.equal(permissionAsks, 1, "permissions requested for extension signers"); + assert.equal(calls.unlock.length, 1); +}); + +test("edit replace-file: unlock failure surfaces the error and returns null", async () => { + const { modal, calls } = makeEditCtx({ gate: { ok: true }, unlockThrows: true }); + const conn = await ensureUploadable(modal, { getSigner: () => capableSigner }); + assert.equal(conn, null); + assert.equal(calls.errors.length, 1); + assert.match(calls.errors[0], /Failed to unlock storage/); +}); + +test("edit replace-file: an already-unlocked connection passes straight through", async () => { + const { modal, calls } = makeEditCtx({ gate: { ok: true } }); + modal.mediaUploader.resolveActiveConnection = async () => ({ + configured: true, + unlocked: true, + credentials: { accessKeyId: "k" }, + provider: "s3", + }); + const conn = await ensureUploadable(modal, { getSigner: () => capableSigner }); + assert.ok(conn); + assert.equal(calls.gates.length, 0, "no prompt when already unlocked"); + assert.equal(calls.unlock.length, 0); +});