Files
bitvid/js/ui/promptDialog.js
T
thePR0M3TH3ANandClaude Opus 4.8 1b17bb8fcc [auth] Opt-in "keep unlocked" so a reload doesn't re-prompt for the PIN (#51)
After unlocking a saved nsec key the user had to re-enter the PIN on every reload,
because the key is only passphrase-encrypted and the in-memory signer is lost on
refresh. Add an opt-in unlocked-key cache with two tiers (maintainer's choice):

- session (default, always): sessionStorage — survives refresh/navigation, cleared
  when the tab/browser closes. Same rough exposure as the in-memory signer.
- persistent (opt-in): the unlock prompt now shows a "Keep me unlocked on this
  device" checkbox with an inline unencrypted-at-rest warning; when checked the key
  is also written to localStorage and survives until site data is cleared.

client.restoreUnlockedSigner(pubkey) re-registers the signer from the cache WITHOUT
a passphrase — it verifies the cached key derives to that pubkey and forgets a
mismatch, so it never adopts another account. It runs proactively from
ensureActiveSignerForPubkey (auto-login/refresh) and lazily from
ensureEncryptionCapableSigner before any prompt, so after a reload the signer is
restored silently and storage/hashtags/DMs/reactions/blocks/subs all just work.
Logout and remove-saved-profile forget the cached key (forgetUnlockedSigner).

SECURITY: while cached, the key is stored decrypted — the passphrase no longer
protects it at rest. Session tier is the default; persistence is opt-in + warned.

Tests: tests/unlocked-key-cache.test.mjs (8 — session vs persistent tiers, promote
persistent->session on read, reject non-hex, forget one, clear all, missing-storage
degrade). Related unlock/switch/session-actor suites green. Lint + build green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 19:51:41 -04:00

155 lines
5.5 KiB
JavaScript

// Promise-based password/PIN prompt rendered with the site's modal styling (sibling of
// confirmDialog). Used to collect the passphrase that unlocks a stored nsec key — e.g.
// when switching to a saved nsec account. Resolves the entered string, or null if the
// user cancels. Builds its own `bv-modal modal-always-on-top` overlay so it stacks above
// other modals (and is typable thanks to the stacked-modal focus fix).
// When `collectRemember` is set, an opt-in "keep unlocked" checkbox is shown and
// the prompt resolves `{ passphrase, remember }` instead of a bare string (cancel
// still resolves null). Callers that don't pass `collectRemember` keep the legacy
// string resolution.
export function showPasswordPrompt(message, options = {}) {
const {
title = "",
confirmLabel = "Unlock",
cancelLabel = "Cancel",
placeholder = "PIN / passphrase",
collectRemember = false,
rememberLabel = "Keep me unlocked on this device",
rememberHint =
"Stored unencrypted on this device until you clear site data. Anyone with access to this browser can use it. Leave off to re-enter after closing the tab.",
rememberDefault = false,
} = options || {};
if (typeof document === "undefined" || !document.body) {
return Promise.resolve(null);
}
return new Promise((resolve) => {
const overlay = document.createElement("div");
overlay.className =
"bv-modal modal-always-on-top items-start justify-center md:items-center";
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true");
const backdrop = document.createElement("div");
backdrop.className = "bv-modal-backdrop";
overlay.appendChild(backdrop);
const sheet = document.createElement("div");
sheet.className = "modal-sheet w-full max-w-md flex flex-col";
sheet.tabIndex = -1;
overlay.appendChild(sheet);
if (title) {
const header = document.createElement("div");
header.className = "modal-header";
const heading = document.createElement("h2");
heading.className = "text-lg font-bold text-text";
heading.textContent = title;
header.appendChild(heading);
sheet.appendChild(header);
}
const body = document.createElement("div");
body.className = "p-5 space-y-3";
if (message) {
const text = document.createElement("p");
text.className = "text-sm text-muted-strong whitespace-pre-line";
text.textContent = String(message);
body.appendChild(text);
}
const input = document.createElement("input");
input.type = "password";
input.className = "input w-full";
input.placeholder = placeholder;
input.autocomplete = "off";
input.setAttribute("aria-label", placeholder);
body.appendChild(input);
let rememberCheckbox = null;
if (collectRemember) {
const rememberWrap = document.createElement("label");
rememberWrap.className = "flex items-start gap-2 cursor-pointer";
rememberCheckbox = document.createElement("input");
rememberCheckbox.type = "checkbox";
rememberCheckbox.className = "mt-1";
rememberCheckbox.checked = Boolean(rememberDefault);
const rememberText = document.createElement("span");
rememberText.className = "text-sm text-muted-strong";
const rememberTitle = document.createElement("span");
rememberTitle.className = "block text-text";
rememberTitle.textContent = rememberLabel;
rememberText.appendChild(rememberTitle);
if (rememberHint) {
const hint = document.createElement("span");
hint.className = "block text-xs text-status-danger";
hint.textContent = rememberHint;
rememberText.appendChild(hint);
}
rememberWrap.appendChild(rememberCheckbox);
rememberWrap.appendChild(rememberText);
body.appendChild(rememberWrap);
}
sheet.appendChild(body);
const footer = document.createElement("div");
footer.className =
"flex items-center justify-end gap-3 border-t border-border/60 p-4";
const cancelBtn = document.createElement("button");
cancelBtn.type = "button";
cancelBtn.className = "btn-ghost focus-ring";
cancelBtn.textContent = cancelLabel;
const confirmBtn = document.createElement("button");
confirmBtn.type = "button";
confirmBtn.className = "btn focus-ring";
confirmBtn.textContent = confirmLabel;
footer.appendChild(cancelBtn);
footer.appendChild(confirmBtn);
sheet.appendChild(footer);
let settled = false;
const cleanup = (result) => {
if (settled) {
return;
}
settled = true;
document.removeEventListener("keydown", onKeydown, true);
overlay.remove();
resolve(result);
};
const submit = () =>
cleanup(
collectRemember
? {
passphrase: input.value,
remember: Boolean(rememberCheckbox && rememberCheckbox.checked),
}
: input.value,
);
function onKeydown(event) {
if (event.key === "Escape") {
event.preventDefault();
cleanup(null);
}
}
cancelBtn.addEventListener("click", () => cleanup(null));
backdrop.addEventListener("click", () => cleanup(null));
confirmBtn.addEventListener("click", submit);
input.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
submit();
}
});
document.addEventListener("keydown", onKeydown, true);
document.body.appendChild(overlay);
input.focus();
});
}
export default showPasswordPrompt;