mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
[ui] upload modal: prompt for the nsec passphrase instead of dead-ending after reload
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 <[email protected]>
This commit is contained in:
1 parent
3e57ab2ea9
commit
f00d2b5f49
4 files changed
+144
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user