diff --git a/js/app.js b/js/app.js index 48da3666..e747d190 100644 --- a/js/app.js +++ b/js/app.js @@ -3494,13 +3494,32 @@ class Application { return false; } - const sessionActorPubkey = this.normalizeHexPubkey( - nostrClient?.sessionActor?.pubkey, - ); - if (sessionActorPubkey && sessionActorPubkey === normalizedPubkey) { + const clientPubkey = this.normalizeHexPubkey(nostrClient?.pubkey); + if (clientPubkey && clientPubkey !== normalizedPubkey) { return false; } + const sessionActor = nostrClient?.sessionActor || null; + const sessionActorPubkey = this.normalizeHexPubkey(sessionActor?.pubkey); + if (sessionActorPubkey && sessionActorPubkey !== normalizedPubkey) { + const rawPrivateKey = + typeof sessionActor?.privateKey === "string" + ? sessionActor.privateKey.trim() + : ""; + const hasEmbeddedPrivateKey = + rawPrivateKey.length > 0 && HEX64_REGEX.test(rawPrivateKey); + + const declaredSource = + typeof sessionActor?.source === "string" + ? sessionActor.source.trim() + : ""; + const isPersisted = sessionActor?.persisted === true; + + if (!hasEmbeddedPrivateKey || declaredSource || isPersisted) { + return false; + } + } + return true; } @@ -3588,6 +3607,34 @@ class Application { } } + refreshChromeElements() { + const doc = typeof document !== "undefined" ? document : null; + const findElement = (id) => { + if (!doc || typeof doc.getElementById !== "function") { + return null; + } + const element = doc.getElementById(id); + if (!element || typeof element.addEventListener !== "function") { + return null; + } + return element; + }; + + this.loginButton = findElement("loginButton"); + this.logoutButton = findElement("logoutButton"); + this.uploadButton = findElement("uploadButton"); + this.profileButton = findElement("profileButton"); + this.closeLoginModalBtn = findElement("closeLoginModal"); + + return { + loginButton: this.loginButton, + logoutButton: this.logoutButton, + uploadButton: this.uploadButton, + profileButton: this.profileButton, + closeLoginModalButton: this.closeLoginModalBtn, + }; + } + resolveSubscriptionsLink() { if (this.subscriptionsLink instanceof HTMLElement) { return this.subscriptionsLink; @@ -3604,7 +3651,20 @@ class Application { } hydrateSidebarNavigation() { + const chromeElements = this.refreshChromeElements(); this.resolveSubscriptionsLink(); + + if (this.appChromeController) { + if (typeof this.appChromeController.setElements === "function") { + this.appChromeController.setElements(chromeElements); + } else { + if (this.appChromeController.elements) { + Object.assign(this.appChromeController.elements, chromeElements); + } + this.appChromeController.initialize(); + } + } + this.syncAuthUiState(); } diff --git a/js/ui/appChromeController.js b/js/ui/appChromeController.js index f8cb7467..1960bd20 100644 --- a/js/ui/appChromeController.js +++ b/js/ui/appChromeController.js @@ -16,11 +16,13 @@ export default class AppChromeController { logger, } = {}) { this.elements = { - logoutButton: elements.logoutButton || null, - profileButton: elements.profileButton || null, - uploadButton: elements.uploadButton || null, - loginButton: elements.loginButton || null, - closeLoginModalButton: elements.closeLoginModalButton || null, + logoutButton: this.normalizeElement(elements.logoutButton), + profileButton: this.normalizeElement(elements.profileButton), + uploadButton: this.normalizeElement(elements.uploadButton), + loginButton: this.normalizeElement(elements.loginButton), + closeLoginModalButton: this.normalizeElement( + elements.closeLoginModalButton, + ), }; this.callbacks = { @@ -59,42 +61,17 @@ export default class AppChromeController { this.logger = logger || defaultLogger; this.isInitialized = false; + this.hasBoundGlobalEvents = false; - this.handleBeforeUnload = this.handleBeforeUnload.bind(this); - this.handleVisibilityChange = this.handleVisibilityChange.bind(this); - this.handlePopstate = this.handlePopstate.bind(this); - this.handleDocumentClick = this.handleDocumentClick.bind(this); - } + this.boundElements = { + logoutButton: null, + profileButton: null, + uploadButton: null, + loginButton: null, + closeLoginModalButton: null, + }; - initialize() { - if (this.isInitialized) { - return; - } - - this.bindLogoutButton(); - this.bindProfileButton(); - this.bindUploadButton(); - this.bindLoginButton(); - this.bindCloseLoginButton(); - this.bindGlobalEvents(); - - this.isInitialized = true; - } - - getTriggerFromEvent(event) { - if (!event) { - return null; - } - return event.currentTarget || event.target || null; - } - - bindLogoutButton() { - const { logoutButton } = this.elements; - if (!logoutButton || typeof logoutButton.addEventListener !== "function") { - return; - } - - logoutButton.addEventListener("click", async () => { + this.handleLogoutClick = async () => { try { if (typeof this.callbacks.requestLogout === "function") { await this.callbacks.requestLogout(); @@ -107,16 +84,9 @@ export default class AppChromeController { this.callbacks.showError(DEFAULT_LOGOUT_ERROR_MESSAGE); } } - }); - } + }; - bindProfileButton() { - const { profileButton } = this.elements; - if (!profileButton || typeof profileButton.addEventListener !== "function") { - return; - } - - profileButton.addEventListener("click", () => { + this.handleProfileClick = () => { if (typeof this.callbacks.showProfileModal !== "function") { return; } @@ -128,19 +98,13 @@ export default class AppChromeController { this.logger.error("Failed to open profile modal:", error); } }); - }); - } + }; - bindUploadButton() { - const { uploadButton } = this.elements; - if (!uploadButton || typeof uploadButton.addEventListener !== "function") { - return; - } - - uploadButton.addEventListener("click", (event) => { + this.handleUploadClick = (event) => { if (typeof this.callbacks.openUploadModal !== "function") { return; } + const triggerElement = this.getTriggerFromEvent(event); try { this.callbacks.openUploadModal({ triggerElement }); @@ -149,16 +113,9 @@ export default class AppChromeController { this.logger.error("Failed to open upload modal:", error); } } - }); - } + }; - bindLoginButton() { - const { loginButton } = this.elements; - if (!loginButton || typeof loginButton.addEventListener !== "function") { - return; - } - - loginButton.addEventListener("click", (event) => { + this.handleLoginClick = (event) => { if (this.logger?.log) { this.logger.log("Login button clicked!"); } @@ -178,19 +135,9 @@ export default class AppChromeController { this.callbacks.onLoginModalOpened(); } } - }); - } + }; - bindCloseLoginButton() { - const { closeLoginModalButton } = this.elements; - if ( - !closeLoginModalButton || - typeof closeLoginModalButton.addEventListener !== "function" - ) { - return; - } - - closeLoginModalButton.addEventListener("click", () => { + this.handleCloseLoginClick = () => { if (this.logger?.log) { this.logger.log("[app.js] closeLoginModal button clicked!"); } @@ -201,7 +148,149 @@ export default class AppChromeController { this.callbacks.onLoginModalClosed(); } } - }); + }; + + this.handleBeforeUnload = this.handleBeforeUnload.bind(this); + this.handleVisibilityChange = this.handleVisibilityChange.bind(this); + this.handlePopstate = this.handlePopstate.bind(this); + this.handleDocumentClick = this.handleDocumentClick.bind(this); + } + + initialize() { + this.bindLogoutButton(); + this.bindProfileButton(); + this.bindUploadButton(); + this.bindLoginButton(); + this.bindCloseLoginButton(); + + if (!this.hasBoundGlobalEvents) { + this.bindGlobalEvents(); + this.hasBoundGlobalEvents = true; + } + + this.isInitialized = true; + } + + getTriggerFromEvent(event) { + if (!event) { + return null; + } + return event.currentTarget || event.target || null; + } + + bindLogoutButton() { + const button = this.normalizeElement(this.elements.logoutButton); + if (this.boundElements.logoutButton === button) { + return; + } + + this.detachBoundElement("logoutButton", this.handleLogoutClick); + + if (button) { + button.addEventListener("click", this.handleLogoutClick); + this.boundElements.logoutButton = button; + } + } + + bindProfileButton() { + const button = this.normalizeElement(this.elements.profileButton); + if (this.boundElements.profileButton === button) { + return; + } + + this.detachBoundElement("profileButton", this.handleProfileClick); + + if (button) { + button.addEventListener("click", this.handleProfileClick); + this.boundElements.profileButton = button; + } + } + + bindUploadButton() { + const button = this.normalizeElement(this.elements.uploadButton); + if (this.boundElements.uploadButton === button) { + return; + } + + this.detachBoundElement("uploadButton", this.handleUploadClick); + + if (button) { + button.addEventListener("click", this.handleUploadClick); + this.boundElements.uploadButton = button; + } + } + + bindLoginButton() { + const button = this.normalizeElement(this.elements.loginButton); + if (this.boundElements.loginButton === button) { + return; + } + + this.detachBoundElement("loginButton", this.handleLoginClick); + + if (button) { + button.addEventListener("click", this.handleLoginClick); + this.boundElements.loginButton = button; + } + } + + bindCloseLoginButton() { + const button = this.normalizeElement(this.elements.closeLoginModalButton); + if (this.boundElements.closeLoginModalButton === button) { + return; + } + + this.detachBoundElement("closeLoginModalButton", this.handleCloseLoginClick); + + if (button) { + button.addEventListener("click", this.handleCloseLoginClick); + this.boundElements.closeLoginModalButton = button; + } + } + + setElements(nextElements = {}) { + if (!nextElements || typeof nextElements !== "object") { + return; + } + + const keys = [ + "logoutButton", + "profileButton", + "uploadButton", + "loginButton", + "closeLoginModalButton", + ]; + + let shouldRebind = false; + + for (const key of keys) { + if (Object.prototype.hasOwnProperty.call(nextElements, key)) { + const normalized = this.normalizeElement(nextElements[key]); + if (this.elements[key] !== normalized) { + this.elements[key] = normalized; + shouldRebind = true; + } + } + } + + if (shouldRebind) { + this.initialize(); + } + } + + detachBoundElement(key, handler) { + const previous = this.boundElements[key]; + if (previous && typeof previous.removeEventListener === "function") { + previous.removeEventListener("click", handler); + } + this.boundElements[key] = null; + } + + normalizeElement(element) { + if (!element || typeof element.addEventListener !== "function") { + return null; + } + return element; } bindGlobalEvents() { diff --git a/tests/app/hydrate-sidebar-navigation.test.mjs b/tests/app/hydrate-sidebar-navigation.test.mjs new file mode 100644 index 00000000..139e7e51 --- /dev/null +++ b/tests/app/hydrate-sidebar-navigation.test.mjs @@ -0,0 +1,96 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { JSDOM } from "jsdom"; + +import { Application } from "../../js/app.js"; + +const VIEW_HTML = ` + + +
+ + + + + + + +
+ + +`; + +test("hydrateSidebarNavigation reveals chrome controls for authenticated viewers", () => { + const dom = new JSDOM(VIEW_HTML, { url: "https://example.com" }); + const { window } = dom; + + const previousWindow = global.window; + const previousDocument = global.document; + + global.window = window; + global.document = window.document; + const previousHTMLElement = global.HTMLElement; + global.HTMLElement = window.HTMLElement; + + try { + const app = Object.create(Application.prototype); + + app.appChromeController = { + receivedElements: null, + setElements(elements) { + this.receivedElements = elements; + }, + }; + + app.userStatus = window.document.getElementById("userStatus"); + app.userPubKey = window.document.getElementById("userPubKey"); + app.subscriptionsLink = null; + app.isUserLoggedIn = () => true; + app.log = () => {}; + app.showError = () => {}; + app.showSuccess = () => {}; + app.showStatus = () => {}; + + app.hydrateSidebarNavigation(); + + const uploadButton = window.document.getElementById("uploadButton"); + const profileButton = window.document.getElementById("profileButton"); + const loginButton = window.document.getElementById("loginButton"); + const logoutButton = window.document.getElementById("logoutButton"); + const subscriptionsLink = window.document.getElementById("subscriptionsLink"); + const closeLoginButton = window.document.getElementById("closeLoginModal"); + + assert.equal(app.uploadButton, uploadButton); + assert.equal(app.profileButton, profileButton); + assert.equal(app.loginButton, loginButton); + assert.equal(app.logoutButton, logoutButton); + assert.equal(app.closeLoginModalBtn, closeLoginButton); + + assert.equal(uploadButton.classList.contains("hidden"), false); + assert.equal(uploadButton.hasAttribute("hidden"), false); + assert.equal(profileButton.classList.contains("hidden"), false); + assert.equal(profileButton.hasAttribute("hidden"), false); + assert.equal(loginButton.classList.contains("hidden"), true); + assert.equal(loginButton.hasAttribute("hidden"), true); + assert.equal(logoutButton.classList.contains("hidden"), false); + assert.equal(subscriptionsLink.classList.contains("hidden"), false); + + assert.ok(app.appChromeController.receivedElements); + assert.equal( + app.appChromeController.receivedElements.uploadButton, + uploadButton, + ); + assert.equal( + app.appChromeController.receivedElements.profileButton, + profileButton, + ); + } finally { + global.window = previousWindow; + global.document = previousDocument; + if (previousHTMLElement === undefined) { + delete global.HTMLElement; + } else { + global.HTMLElement = previousHTMLElement; + } + } +}); diff --git a/tests/app/is-user-logged-in.test.mjs b/tests/app/is-user-logged-in.test.mjs new file mode 100644 index 00000000..84d503d6 --- /dev/null +++ b/tests/app/is-user-logged-in.test.mjs @@ -0,0 +1,132 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { Application } from "../../js/app.js"; +import { nostrClient } from "../../js/nostrClientFacade.js"; + +const HEX64_REGEX = /^[0-9a-f]{64}$/i; + +function createTestApp(pubkey) { + const app = { + _pubkey: pubkey, + normalizeHexPubkey(value) { + if (typeof value !== "string") { + return null; + } + + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + + if (HEX64_REGEX.test(trimmed)) { + return trimmed.toLowerCase(); + } + + return null; + }, + isUserLoggedIn: Application.prototype.isUserLoggedIn, + }; + + Object.defineProperty(app, "pubkey", { + get() { + return this._pubkey; + }, + set(value) { + this._pubkey = value; + }, + }); + + return app; +} + +test("isUserLoggedIn returns false when no user pubkey is set", () => { + const previousPubkey = nostrClient.pubkey; + const previousSessionActor = nostrClient.sessionActor; + + try { + nostrClient.pubkey = null; + nostrClient.sessionActor = null; + + const app = createTestApp(null); + assert.equal(app.isUserLoggedIn(), false); + } finally { + nostrClient.pubkey = previousPubkey; + nostrClient.sessionActor = previousSessionActor; + } +}); + +test("isUserLoggedIn treats extension logins as authenticated", () => { + const previousPubkey = nostrClient.pubkey; + const previousSessionActor = nostrClient.sessionActor; + + try { + const pubkey = "893a31fa5a6481295bb76ea2dd8d5ec70640e6e6ad398e6547d771e8256af11f"; + nostrClient.pubkey = pubkey; + nostrClient.sessionActor = { pubkey }; + + const app = createTestApp(pubkey); + assert.equal(app.isUserLoggedIn(), true); + } finally { + nostrClient.pubkey = previousPubkey; + nostrClient.sessionActor = previousSessionActor; + } +}); + +test("isUserLoggedIn guards against mismatched nostrClient state", () => { + const previousPubkey = nostrClient.pubkey; + const previousSessionActor = nostrClient.sessionActor; + + try { + const pubkey = "a".repeat(64); + nostrClient.pubkey = "b".repeat(64); + nostrClient.sessionActor = { pubkey }; + + const app = createTestApp(pubkey); + assert.equal(app.isUserLoggedIn(), false); + } finally { + nostrClient.pubkey = previousPubkey; + nostrClient.sessionActor = previousSessionActor; + } +}); + +test("isUserLoggedIn ignores anonymous session actor mismatches", () => { + const previousPubkey = nostrClient.pubkey; + const previousSessionActor = nostrClient.sessionActor; + + try { + const pubkey = "c".repeat(64); + nostrClient.pubkey = pubkey; + nostrClient.sessionActor = { + pubkey: "d".repeat(64), + privateKey: "e".repeat(64), + createdAt: Date.now(), + }; + + const app = createTestApp(pubkey); + assert.equal(app.isUserLoggedIn(), true); + } finally { + nostrClient.pubkey = previousPubkey; + nostrClient.sessionActor = previousSessionActor; + } +}); + +test("isUserLoggedIn rejects mismatched managed session actors", () => { + const previousPubkey = nostrClient.pubkey; + const previousSessionActor = nostrClient.sessionActor; + + try { + const pubkey = "f".repeat(64); + nostrClient.pubkey = pubkey; + nostrClient.sessionActor = { + pubkey: "0".repeat(64), + source: "nsec", + }; + + const app = createTestApp(pubkey); + assert.equal(app.isUserLoggedIn(), false); + } finally { + nostrClient.pubkey = previousPubkey; + nostrClient.sessionActor = previousSessionActor; + } +}); diff --git a/tests/ui/app-chrome-controller.test.mjs b/tests/ui/app-chrome-controller.test.mjs new file mode 100644 index 00000000..54d1f045 --- /dev/null +++ b/tests/ui/app-chrome-controller.test.mjs @@ -0,0 +1,58 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { JSDOM } from "jsdom"; + +import AppChromeController from "../../js/ui/appChromeController.js"; + +test("AppChromeController binds upload button when elements hydrate later", () => { + const dom = new JSDOM(``, { + url: "https://example.com", + }); + const { window } = dom; + + const previousWindow = global.window; + const previousDocument = global.document; + + global.window = window; + global.document = window.document; + + try { + const openCalls = []; + const controller = new AppChromeController({ + callbacks: { + openUploadModal: (detail) => { + openCalls.push(detail); + }, + }, + logger: { + error: () => {}, + log: () => {}, + }, + }); + + controller.initialize(); + + const uploadButton = window.document.getElementById("upload"); + controller.setElements({ uploadButton }); + + uploadButton.dispatchEvent(new window.Event("click", { bubbles: true })); + + assert.equal(openCalls.length, 1); + assert.equal(openCalls[0]?.triggerElement, uploadButton); + + const replacement = window.document.createElement("button"); + window.document.body.appendChild(replacement); + + controller.setElements({ uploadButton: replacement }); + + uploadButton.dispatchEvent(new window.Event("click", { bubbles: true })); + assert.equal(openCalls.length, 1); + + replacement.dispatchEvent(new window.Event("click", { bubbles: true })); + assert.equal(openCalls.length, 2); + assert.equal(openCalls[1]?.triggerElement, replacement); + } finally { + global.window = previousWindow; + global.document = previousDocument; + } +});