mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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]>
155 lines
5.5 KiB
JavaScript
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;
|