From 094559b618a53f5b879ad96f0d4d077acdbea1f7 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 21 Oct 2025 17:46:41 -0400 Subject: [PATCH] Add per-profile settings store and mask wallet inputs --- js/services/nwcSettingsService.js | 32 ++-- js/state/profileSettingsStore.js | 74 +++++++ js/ui/profileModalController.js | 202 +++++++++++++++++--- tests/nwc-settings-service.test.mjs | 97 ++++++++++ tests/profile-modal-controller.test.mjs | 63 ++++++ tests/state/profile-settings-store.test.mjs | 92 +++++++++ 6 files changed, 520 insertions(+), 40 deletions(-) create mode 100644 js/state/profileSettingsStore.js create mode 100644 tests/nwc-settings-service.test.mjs create mode 100644 tests/state/profile-settings-store.test.mjs diff --git a/js/services/nwcSettingsService.js b/js/services/nwcSettingsService.js index e9a55b18..e0fb5c49 100644 --- a/js/services/nwcSettingsService.js +++ b/js/services/nwcSettingsService.js @@ -6,6 +6,7 @@ import { } from "../nwcSettings.js"; import { MAX_WALLET_DEFAULT_ZAP } from "../config.js"; import { devLogger, userLogger } from "../utils/logger.js"; +import createProfileSettingsStore from "../state/profileSettingsStore.js"; const NWC_URI_SCHEME = "nostr+walletconnect://"; @@ -53,7 +54,10 @@ export default class NwcSettingsService { this.createDefaultSettings = isFunction(createDefaultSettings) ? createDefaultSettings : () => createDefaultNwcSettings(); - this.cache = new Map(); + this.cache = createProfileSettingsStore({ + clone: (value) => this.cloneSettings(value), + logger: this.logger.dev, + }); } cloneSettings(settings) { @@ -101,7 +105,8 @@ export default class NwcSettingsService { ? { ...settings } : this.createDefaultNwcSettings(); this.cache.set(normalized, record); - return this.cloneSettings(record); + const cached = this.cache.get(normalized); + return cached || this.createDefaultNwcSettings(); } catch (error) { if (isFunction(this.logger.user?.warn)) { this.logger.user.warn( @@ -111,7 +116,8 @@ export default class NwcSettingsService { } const fallback = this.createDefaultNwcSettings(); this.cache.set(normalized, fallback); - return this.cloneSettings(fallback); + const cached = this.cache.get(normalized); + return cached || this.createDefaultNwcSettings(); } } @@ -121,7 +127,7 @@ export default class NwcSettingsService { return this.createDefaultNwcSettings(); } const cached = this.cache.get(normalized); - return cached ? this.cloneSettings(cached) : this.createDefaultNwcSettings(); + return cached ? cached : this.createDefaultNwcSettings(); } createDefaultNwcSettings() { @@ -178,7 +184,8 @@ export default class NwcSettingsService { ? { ...updated } : this.createDefaultNwcSettings(); this.cache.set(normalized, record); - return this.cloneSettings(record); + const cached = this.cache.get(normalized); + return cached || this.createDefaultNwcSettings(); } catch (error) { if (isFunction(this.logger.user?.warn)) { this.logger.user.warn( @@ -294,14 +301,6 @@ export default class NwcSettingsService { const normalizedActive = this.getNormalizedPubkey( pubkey !== undefined ? pubkey : this.getActivePubkey(), ); - const normalizedPrevious = this.getNormalizedPubkey(previousPubkey); - - if ( - normalizedPrevious && - (!normalizedActive || normalizedPrevious !== normalizedActive) - ) { - await this.clearStoredNwcSettings(normalizedPrevious, { silent: true }); - } if (normalizedActive) { await this.hydrateNwcSettingsForPubkey(normalizedActive); @@ -315,13 +314,14 @@ export default class NwcSettingsService { previousPubkey !== undefined ? previousPubkey : pubkey, ); if (normalizedPrevious) { - await this.clearStoredNwcSettings(normalizedPrevious, { silent: false }); + this.cache.delete(normalizedPrevious); } - this.clearCache(); return this.createDefaultNwcSettings(); } onIdentityChanged() { - this.clearCache(); + // Intentionally preserve cached settings so profile switches retain + // per-account configuration. Explicit cache clearing remains + // available via `clearCache()` when required by callers. } } diff --git a/js/state/profileSettingsStore.js b/js/state/profileSettingsStore.js new file mode 100644 index 00000000..46c05bc6 --- /dev/null +++ b/js/state/profileSettingsStore.js @@ -0,0 +1,74 @@ +import { devLogger } from "../utils/logger.js"; + +function noopClone(value) { + return value; +} + +export function createProfileSettingsStore({ clone = noopClone, logger = devLogger } = {}) { + const cache = new Map(); + + const cloneValue = (value) => { + try { + return clone(value); + } catch (error) { + if (logger && typeof logger.warn === "function") { + logger.warn("[profileSettingsStore] Failed to clone value", error); + } + return value; + } + }; + + const store = { + set(key, value) { + if (!key) { + return store; + } + cache.set(key, cloneValue(value)); + return store; + }, + get(key) { + if (!key || !cache.has(key)) { + return undefined; + } + return cloneValue(cache.get(key)); + }, + has(key) { + if (!key) { + return false; + } + return cache.has(key); + }, + delete(key) { + if (!key) { + return false; + } + return cache.delete(key); + }, + clear() { + cache.clear(); + return store; + }, + keys() { + return Array.from(cache.keys()); + }, + values() { + return Array.from(cache.values()).map((value) => cloneValue(value)); + }, + entries() { + return Array.from(cache.entries()).map(([key, value]) => [ + key, + cloneValue(value), + ]); + }, + }; + + Object.defineProperty(store, "size", { + get() { + return cache.size; + }, + }); + + return store; +} + +export default createProfileSettingsStore; diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index 656f03a4..7dcc33f6 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -21,6 +21,7 @@ const DEFAULT_ADMIN_DM_IMAGE_URL = "https://beta.bitvid.network/assets/jpg/video-thumbnail-fallback.jpg"; const DEFAULT_BITVID_WEBSITE_URL = "https://bitvid.network/"; const NWC_URI_SCHEME = "nostr+walletconnect://"; +const SECRET_PLACEHOLDER = "*****"; const DEFAULT_MAX_WALLET_DEFAULT_ZAP = 100000000; const DEFAULT_SAVED_PROFILE_LABEL = "Saved profile"; @@ -1262,7 +1263,15 @@ export class ProfileModalController { } if (this.walletUriInput instanceof HTMLElement) { + this.walletUriInput.addEventListener("focus", () => { + this.revealSecretInputValue(this.walletUriInput); + }); + this.walletUriInput.addEventListener("blur", () => { + this.handleSecretInputBlur(this.walletUriInput); + this.applyWalletControlState(); + }); this.walletUriInput.addEventListener("input", () => { + this.handleSecretInputChange(this.walletUriInput); this.applyWalletControlState(); }); } @@ -2984,10 +2993,7 @@ export class ProfileModalController { applyWalletControlState() { const hasActive = Boolean(this.normalizeHexPubkey(this.getActivePubkey())); const busy = this.isWalletBusy(); - const uriValue = - typeof this.walletUriInput?.value === "string" - ? this.walletUriInput.value.trim() - : ""; + const uriValue = this.getSecretInputValue(this.walletUriInput); const hasUri = uriValue.length > 0; const applyDisabledState = (element, disabled) => { @@ -3049,20 +3055,17 @@ export class ProfileModalController { refreshWalletPaneState() { const hasActive = Boolean(this.normalizeHexPubkey(this.getActivePubkey())); - const setInputValue = (element, value) => { - if (element && typeof element === "object" && "value" in element) { + if (!hasActive) { + this.setSecretInputValue(this.walletUriInput, ""); + if (this.walletDefaultZapInput && "value" in this.walletDefaultZapInput) { try { - element.value = value; + this.walletDefaultZapInput.value = ""; } catch (error) { - if (element instanceof HTMLElement) { - element.setAttribute("data-value", value); + if (this.walletDefaultZapInput instanceof HTMLElement) { + this.walletDefaultZapInput.setAttribute("data-value", ""); } } } - }; - if (!hasActive) { - setInputValue(this.walletUriInput, ""); - setInputValue(this.walletDefaultZapInput, ""); this.updateWalletStatus("Sign in to connect a wallet.", "info"); this.applyWalletControlState(); return; @@ -3072,13 +3075,20 @@ export class ProfileModalController { if (!settings || typeof settings !== "object") { settings = this.services.nwcSettings.createDefaultNwcSettings(); } - setInputValue(this.walletUriInput, settings.nwcUri || ""); - setInputValue( - this.walletDefaultZapInput, - settings.defaultZap === null || settings.defaultZap === undefined - ? "" - : String(settings.defaultZap), - ); + this.setSecretInputValue(this.walletUriInput, settings.nwcUri || ""); + if (this.walletDefaultZapInput && "value" in this.walletDefaultZapInput) { + const defaultZapValue = + settings.defaultZap === null || settings.defaultZap === undefined + ? "" + : String(settings.defaultZap); + try { + this.walletDefaultZapInput.value = defaultZapValue; + } catch (error) { + if (this.walletDefaultZapInput instanceof HTMLElement) { + this.walletDefaultZapInput.setAttribute("data-value", defaultZapValue); + } + } + } if (settings.nwcUri) { this.updateWalletStatus( @@ -3092,11 +3102,155 @@ export class ProfileModalController { this.applyWalletControlState(); } + isSecretInputElement(element) { + if (!element || typeof element !== "object") { + return false; + } + if (typeof HTMLInputElement !== "undefined" && element instanceof HTMLInputElement) { + return true; + } + return typeof element.value === "string"; + } + + sanitizeSecretValue(value) { + return typeof value === "string" ? value.trim() : ""; + } + + getSecretInputValue(element) { + if (!this.isSecretInputElement(element)) { + return ""; + } + + const placeholder = + typeof element.dataset?.secretPlaceholder === "string" + ? element.dataset.secretPlaceholder + : SECRET_PLACEHOLDER; + const stored = this.sanitizeSecretValue( + typeof element.dataset?.secretValue === "string" + ? element.dataset.secretValue + : "", + ); + const raw = this.sanitizeSecretValue(element.value); + const isMasked = element.dataset?.secretMasked === "true"; + + if (isMasked && placeholder && raw === placeholder) { + return stored; + } + + if (!raw && isMasked) { + return stored; + } + + return raw; + } + + setSecretInputValue(element, value) { + if (!this.isSecretInputElement(element)) { + return; + } + + const sanitized = this.sanitizeSecretValue(value); + if (!sanitized) { + if (element.dataset) { + delete element.dataset.secretValue; + delete element.dataset.secretMasked; + delete element.dataset.secretPlaceholder; + } + element.value = ""; + return; + } + + const placeholder = SECRET_PLACEHOLDER; + if (element.dataset) { + element.dataset.secretValue = sanitized; + element.dataset.secretPlaceholder = placeholder; + element.dataset.secretMasked = "true"; + } + element.value = placeholder; + } + + revealSecretInputValue(element) { + if (!this.isSecretInputElement(element)) { + return; + } + + const stored = this.sanitizeSecretValue( + typeof element.dataset?.secretValue === "string" + ? element.dataset.secretValue + : "", + ); + + if (!stored) { + if (element.dataset) { + delete element.dataset.secretMasked; + } + return; + } + + if (element.dataset) { + element.dataset.secretMasked = "false"; + } + element.value = stored; + try { + if (typeof element.setSelectionRange === "function") { + const length = stored.length; + element.setSelectionRange(length, length); + } + } catch (error) { + // Ignore selection errors on unsupported input types. + } + } + + handleSecretInputChange(element) { + if (!this.isSecretInputElement(element)) { + return; + } + + const value = this.sanitizeSecretValue(element.value); + if (!value) { + if (element.dataset) { + element.dataset.secretMasked = "false"; + delete element.dataset.secretValue; + } + return; + } + + if (element.dataset) { + element.dataset.secretValue = value; + element.dataset.secretMasked = "false"; + if (!element.dataset.secretPlaceholder) { + element.dataset.secretPlaceholder = SECRET_PLACEHOLDER; + } + } + } + + handleSecretInputBlur(element) { + if (!this.isSecretInputElement(element)) { + return; + } + + const value = this.sanitizeSecretValue(this.getSecretInputValue(element)); + if (!value) { + if (element.dataset) { + delete element.dataset.secretValue; + delete element.dataset.secretMasked; + delete element.dataset.secretPlaceholder; + } + element.value = ""; + return; + } + + if (element.dataset) { + element.dataset.secretValue = value; + element.dataset.secretPlaceholder = + element.dataset.secretPlaceholder || SECRET_PLACEHOLDER; + element.dataset.secretMasked = "true"; + } + element.value = element.dataset?.secretPlaceholder || SECRET_PLACEHOLDER; + } + getWalletFormValues() { - const uri = - typeof this.walletUriInput?.value === "string" - ? this.walletUriInput.value.trim() - : ""; + const uri = this.getSecretInputValue(this.walletUriInput); const defaultZapRaw = typeof this.walletDefaultZapInput?.value === "string" ? this.walletDefaultZapInput.value.trim() diff --git a/tests/nwc-settings-service.test.mjs b/tests/nwc-settings-service.test.mjs new file mode 100644 index 00000000..79f02bf7 --- /dev/null +++ b/tests/nwc-settings-service.test.mjs @@ -0,0 +1,97 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import NwcSettingsService from "../js/services/nwcSettingsService.js"; + +const DEFAULT_SETTINGS = { + nwcUri: "", + defaultZap: null, + lastChecked: null, + version: "", +}; + +function createService({ saved = new Map(), activeRef }) { + const service = new NwcSettingsService({ + normalizeHexPubkey: (value) => (typeof value === "string" ? value : null), + getActivePubkey: () => activeRef.value, + loadSettings: async (pubkey) => saved.get(pubkey), + saveSettings: async (pubkey, partial = {}) => { + const current = saved.get(pubkey) || {}; + const next = { ...current, ...partial }; + saved.set(pubkey, next); + return next; + }, + clearSettings: async (pubkey) => { + saved.delete(pubkey); + }, + createDefaultSettings: () => ({ ...DEFAULT_SETTINGS }), + }); + return service; +} + +test("nwc settings service preserves settings across profile switches", async () => { + const saved = new Map([ + ["pub1", { nwcUri: "nostr+walletconnect://pub1?relay=wss://one" }], + ["pub2", { nwcUri: "nostr+walletconnect://pub2?relay=wss://two" }], + ]); + const activeRef = { value: null }; + const service = createService({ saved, activeRef }); + + activeRef.value = "pub1"; + await service.onLogin({ pubkey: "pub1", identityChanged: true }); + const first = service.getActiveNwcSettings(); + assert.equal(first.nwcUri, "nostr+walletconnect://pub1?relay=wss://one"); + + activeRef.value = "pub2"; + await service.onLogin({ + pubkey: "pub2", + previousPubkey: "pub1", + identityChanged: true, + }); + const second = service.getActiveNwcSettings(); + assert.equal(second.nwcUri, "nostr+walletconnect://pub2?relay=wss://two"); + + assert.equal(saved.has("pub1"), true, "previous profile data should stay persisted"); + assert.equal(service.cache.has("pub1"), true, "previous profile should remain cached"); + + activeRef.value = "pub1"; + await service.onLogin({ + pubkey: "pub1", + previousPubkey: "pub2", + identityChanged: true, + }); + const roundTrip = service.getActiveNwcSettings(); + assert.equal(roundTrip.nwcUri, "nostr+walletconnect://pub1?relay=wss://one"); + + roundTrip.nwcUri = "mutated"; + const unchanged = service.getActiveNwcSettings(); + assert.equal( + unchanged.nwcUri, + "nostr+walletconnect://pub1?relay=wss://one", + "mutating returned settings should not affect cached data", + ); + + activeRef.value = "pub2"; + await service.onLogout({ pubkey: "pub2" }); + assert.equal(saved.has("pub2"), true, "logout should not clear persisted settings"); + assert.equal(service.cache.has("pub2"), false, "logout should drop cached entry for account"); +}); + +test("updateActiveNwcSettings returns cloned values", async () => { + const saved = new Map(); + const activeRef = { value: "pub3" }; + const service = createService({ saved, activeRef }); + + const updated = await service.updateActiveNwcSettings({ + nwcUri: "nostr+walletconnect://pub3?relay=wss://three", + defaultZap: 777, + }); + + assert.equal(updated.nwcUri, "nostr+walletconnect://pub3?relay=wss://three"); + assert.equal(updated.defaultZap, 777); + + updated.nwcUri = "mutated"; + const cached = service.getActiveNwcSettings(); + assert.equal(cached.nwcUri, "nostr+walletconnect://pub3?relay=wss://three"); + assert.equal(saved.get("pub3").nwcUri, "nostr+walletconnect://pub3?relay=wss://three"); +}); diff --git a/tests/profile-modal-controller.test.mjs b/tests/profile-modal-controller.test.mjs index 851806ed..b55547aa 100644 --- a/tests/profile-modal-controller.test.mjs +++ b/tests/profile-modal-controller.test.mjs @@ -433,6 +433,69 @@ for (const _ of [0]) { }); }); + test('wallet URI input masks persisted values and restores on focus', async (t) => { + const controller = createController(); + await controller.load(); + + controller.state.setActivePubkey('a'.repeat(64)); + + await controller.show('wallet'); + + const sampleUri = + 'nostr+walletconnect://pub1?relay=wss://relay.example.com'; + await controller.services.nwcSettings.updateActiveNwcSettings({ + nwcUri: sampleUri, + defaultZap: 21, + }); + + controller.refreshWalletPaneState(); + + assert.ok( + controller.walletUriInput instanceof window.HTMLElement, + 'wallet URI input should exist', + ); + assert.equal(controller.walletUriInput.value, '*****'); + assert.equal(controller.walletUriInput.dataset.secretValue, sampleUri); + assert.equal( + controller.walletDisconnectButton?.classList.contains('hidden'), + false, + 'disconnect button should remain visible when a URI exists', + ); + + const formValues = controller.getWalletFormValues(); + assert.equal(formValues.uri, sampleUri); + + controller.walletUriInput.dispatchEvent(new window.Event('focus')); + assert.equal(controller.walletUriInput.value, sampleUri); + + controller.walletUriInput.dispatchEvent(new window.Event('blur')); + assert.equal(controller.walletUriInput.value, '*****'); + + controller.walletUriInput.dispatchEvent(new window.Event('focus')); + const updatedUri = `${sampleUri}&name=bitvid`; + controller.walletUriInput.value = updatedUri; + controller.walletUriInput.dispatchEvent(new window.Event('input')); + controller.walletUriInput.dispatchEvent(new window.Event('blur')); + + const updatedValues = controller.getWalletFormValues(); + assert.equal(updatedValues.uri, updatedUri); + assert.equal(controller.walletUriInput.value, '*****'); + + controller.walletUriInput.dispatchEvent(new window.Event('focus')); + controller.walletUriInput.value = ''; + controller.walletUriInput.dispatchEvent(new window.Event('input')); + controller.walletUriInput.dispatchEvent(new window.Event('blur')); + + assert.equal(controller.walletUriInput.value, ''); + assert.equal(controller.walletUriInput.dataset.secretValue, undefined); + + t.after(() => { + try { + controller.hide({ silent: true }); + } catch {} + }); + }); + test('Profile modal uses abbreviated npub display', async () => { const sampleProfiles = [ { diff --git a/tests/state/profile-settings-store.test.mjs b/tests/state/profile-settings-store.test.mjs new file mode 100644 index 00000000..9fdb3e77 --- /dev/null +++ b/tests/state/profile-settings-store.test.mjs @@ -0,0 +1,92 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import createProfileSettingsStore from "../../js/state/profileSettingsStore.js"; + +function createClone(value) { + if (!value || typeof value !== "object") { + return value; + } + return { ...value }; +} + +test("profile settings store clones values and tracks entries", () => { + const store = createProfileSettingsStore({ clone: createClone }); + const first = { uri: "nostr+walletconnect://pub1" }; + const second = { uri: "nostr+walletconnect://pub2" }; + + const result = store.set("pub1", first); + assert.strictEqual(result, store, "set() should return the store instance"); + assert.equal(store.size, 1); + + store.set("pub2", second); + assert.equal(store.size, 2); + + const fromFirst = store.get("pub1"); + assert.deepEqual(fromFirst, first); + assert.notStrictEqual( + fromFirst, + first, + "get() should return a cloned copy to avoid external mutation", + ); + + fromFirst.uri = "mutated"; + const reloaded = store.get("pub1"); + assert.equal( + reloaded.uri, + "nostr+walletconnect://pub1", + "mutating a retrieved object should not impact stored values", + ); + + const entries = store.entries(); + assert.deepEqual( + entries, + [ + ["pub1", { uri: "nostr+walletconnect://pub1" }], + ["pub2", { uri: "nostr+walletconnect://pub2" }], + ], + ); + + const keys = store.keys(); + assert.deepEqual(keys.sort(), ["pub1", "pub2"]); + + const values = store.values(); + assert.deepEqual(values, [ + { uri: "nostr+walletconnect://pub1" }, + { uri: "nostr+walletconnect://pub2" }, + ]); + assert.notStrictEqual(values[0], fromFirst); + + assert.equal(store.has("pub1"), true); + assert.equal(store.delete("pub1"), true); + assert.equal(store.has("pub1"), false); + assert.equal(store.size, 1); + + store.clear(); + assert.equal(store.size, 0); + assert.equal(store.get("pub2"), undefined); +}); + +test("profile settings store ignores falsy keys and survives clone failures", () => { + const loggerCalls = []; + const store = createProfileSettingsStore({ + clone: () => { + throw new Error("clone failure"); + }, + logger: { + warn: (message, error) => { + loggerCalls.push([message, error?.message]); + }, + }, + }); + + store.set(null, { foo: "bar" }); + assert.equal(store.size, 0, "falsy keys should be ignored"); + + store.set("pub1", { foo: "bar" }); + assert.equal(store.size, 1); + + const value = store.get("pub1"); + assert.deepEqual(value, { foo: "bar" }); + assert.equal(loggerCalls.length > 0, true, "clone failure should be logged"); +});