diff --git a/components/login-modal.html b/components/login-modal.html index 3faf0634..28cbcff4 100644 --- a/components/login-modal.html +++ b/components/login-modal.html @@ -338,8 +338,7 @@

Pair a remote signer

- Scan the QR code with your signer or share the connect link to start a - Nostr Connect session. + Scan the QR code with your signer (like Amber or NsecBunker) to login.

-
-
+
+
-
- - -
-
- + +
-
-
-
+ +
diff --git a/docs/nip46-roadmap.md b/docs/nip46-roadmap.md new file mode 100644 index 00000000..aaca171c --- /dev/null +++ b/docs/nip46-roadmap.md @@ -0,0 +1,52 @@ +# NIP-46 Roadmap & Features + +This document outlines the current state of our NIP-46 (Nostr Connect) implementation and tracks future enhancements. The goal is to provide a seamless, secure, and user-friendly remote signing experience. + +## Current State + +As of the latest refactor, the NIP-46 login flow has been simplified to prioritize ease of use for the majority of users: + +* **QR-First UX**: The login modal immediately presents a QR code for easy pairing with mobile signers (e.g., Amber, NsecBunker). +* **One-Click Copy**: A prominent "Copy Connect Link" button allows users to quickly grab the `nostrconnect://` URI for manual pasting if needed. +* **Hidden Complexity**: Advanced options like manual bunker URL entry and relay configuration are hidden by default to reduce cognitive load. +* **Manual Fallback**: A "Trouble scanning?" toggle reveals the raw URI for debugging or manual copying in edge cases. +* **Automatic Persistence**: Sessions are automatically remembered to streamline future logins. + +## Planned Features & Improvements + +The following features are candidates for future implementation: + +### 1. Manual Bunker Connection +* **Description**: Allow users to manually paste a `bunker://` URL provided by their signer service. +* **Use Case**: Users who set up a remote signer on a server or desktop application that provides a static connection string. +* **Implementation**: Restore the manual input toggle in a "Advanced" or "Manual" tab within the modal. + +### 2. Advanced Relay Configuration +* **Description**: Allow users to customize the relays used for the NIP-46 handshake and subsequent communication. +* **Use Case**: Users with strict privacy requirements or those using private relays for their signing devices. +* **Implementation**: Add a "Relay Settings" accordion or step in the connection flow. + +### 3. NIP-05 Discovery +* **Description**: Allow users to enter a NIP-05 address (e.g., `user@domain.com`) to discover their signer's relay and public key automatically. +* **Use Case**: Simplifies the login process to just an email-like identifier, similar to regular web logins. +* **Implementation**: Query the `.well-known/nostr.json` of the domain to resolve the NIP-46 config. + +### 4. Two-Way Pairing / Auth +* **Description**: Support flows where the client (this app) scans a QR code generated by the signer, rather than the signer scanning the client. +* **Use Case**: Desktop users pairing with a mobile device where the desktop has a webcam. + +### 5. Session Management UI +* **Description**: A dedicated settings panel to view connected signers, revoke permissions, or forget saved sessions. +* **Use Case**: Users managing multiple signing devices or rotating keys. + +### 6. Multi-Account Support via NIP-46 +* **Description**: Better support for signers that manage multiple keys (personas) behind a single connection. +* **Use Case**: Users who want to switch between "Personal", "Work", and "Anon" profiles without reconnecting. + +## Known Issues + +* **QR Code Density**: Very long URIs (with many relays or long secrets) can create dense QR codes that are hard to scan on low-resolution screens. +* **Browser Compatibility**: The "Copy" button relies on the Clipboard API, which requires a secure context (HTTPS) and active focus. + +--- +*Last Updated: ${new Date().toISOString().split('T')[0]}* diff --git a/js/ui/loginModalController.js b/js/ui/loginModalController.js index 6a22a169..291d329d 100644 --- a/js/ui/loginModalController.js +++ b/js/ui/loginModalController.js @@ -1140,19 +1140,14 @@ export default class LoginModalController { const handshakePanel = form.querySelector("[data-nip46-handshake-panel]"); const qrContainer = form.querySelector("[data-nip46-qr]"); const handshakeInput = form.querySelector("[data-nip46-handshake-uri]"); + const debugUriContainer = form.querySelector("[data-nip46-debug-uri]"); const copyButton = form.querySelector("[data-nip46-copy-uri]"); - const secretNode = form.querySelector("[data-nip46-secret]"); const statusNode = form.querySelector("[data-nip46-status]"); const authContainer = form.querySelector("[data-nip46-auth]"); const authMessageNode = form.querySelector("[data-nip46-auth-message]"); const authOpenButton = form.querySelector("[data-nip46-auth-open]"); - const manualToggle = form.querySelector("[data-nip46-toggle-manual]"); - const manualFields = form.querySelector("[data-nip46-manual-fields]"); - const manualInput = form.querySelector("[data-nip46-legacy-uri]"); - const rememberCheckbox = form.querySelector("[data-nip46-remember]"); const reuseButton = form.querySelector("[data-nip46-reuse]"); const cancelButton = form.querySelector("[data-nip46-cancel]"); - const submitButton = form.querySelector("[data-nip46-submit]"); const errorNode = form.querySelector("[data-nip46-error]"); const storedMetadata = this.getStoredNip46Metadata(); @@ -1178,10 +1173,12 @@ export default class LoginModalController { this.modalBody.appendChild(form); this.activeNip46Form = form; - if (handshakeInput instanceof HTMLTextAreaElement) { + if (handshakeInput instanceof HTMLInputElement) { handshakeInput.readOnly = true; handshakeInput.value = ""; - handshakeInput.placeholder = "Waiting for connect link…"; + } + if (debugUriContainer instanceof HTMLElement) { + debugUriContainer.textContent = "Waiting for connect link..."; } if (copyButton instanceof HTMLButtonElement) { copyButton.disabled = true; @@ -1236,7 +1233,6 @@ export default class LoginModalController { const resetStatus = () => setStatus("", "info"); let qrInstance = null; - let manualMode = false; let pendingAuthUrl = ""; const setAuthChallenge = (url) => { @@ -1264,18 +1260,6 @@ export default class LoginModalController { const setPendingState = (pending) => { const disabled = pending === true; - if (submitButton instanceof HTMLButtonElement) { - submitButton.disabled = disabled; - } - if (rememberCheckbox instanceof HTMLInputElement) { - rememberCheckbox.disabled = disabled; - } - if (manualToggle instanceof HTMLButtonElement) { - manualToggle.disabled = disabled; - } - if (manualInput instanceof HTMLTextAreaElement) { - manualInput.disabled = disabled; - } if (reuseButton instanceof HTMLButtonElement) { reuseButton.disabled = disabled; } @@ -1284,7 +1268,7 @@ export default class LoginModalController { } if (copyButton instanceof HTMLButtonElement) { const hasUri = - handshakeInput instanceof HTMLTextAreaElement && + handshakeInput instanceof HTMLInputElement && !!handshakeInput.value; copyButton.disabled = disabled || !hasUri; } @@ -1295,23 +1279,21 @@ export default class LoginModalController { detail && typeof detail === "object" ? detail.uri || detail.connectionString || "" : ""; - if (handshakeInput instanceof HTMLTextAreaElement) { + if (!uri) { + setError("Failed to generate connection link. Please try again."); + return; + } + + if (handshakeInput instanceof HTMLInputElement) { handshakeInput.value = uri; } + if (debugUriContainer instanceof HTMLElement) { + debugUriContainer.textContent = uri; + } if (copyButton instanceof HTMLButtonElement) { copyButton.disabled = !uri; } - if (secretNode instanceof HTMLElement) { - const secretValue = - detail && typeof detail.secret === "string" ? detail.secret.trim() : ""; - if (secretValue) { - secretNode.textContent = `Secret: ${secretValue}`; - secretNode.classList.remove("hidden"); - } else { - secretNode.textContent = ""; - secretNode.classList.add("hidden"); - } - } + if (qrContainer instanceof HTMLElement) { qrContainer.innerHTML = ""; const resolveTokenColor = (tokenName, fallback) => { @@ -1354,7 +1336,7 @@ export default class LoginModalController { if (fallback) { fallback.className = "text-center text-xs text-text-muted"; fallback.textContent = - "Unable to render QR code. Use the connect link below."; + "Unable to render QR code. Use the copy button below."; qrContainer.appendChild(fallback); } } @@ -1363,9 +1345,6 @@ export default class LoginModalController { } } setPendingState(false); - if (submitButton instanceof HTMLButtonElement) { - submitButton.disabled = true; - } resetStatus(); clearAuthChallenge(); setError(""); @@ -1411,41 +1390,12 @@ export default class LoginModalController { setAuthChallenge(normalized); }; - const applyManualMode = (enabled) => { - manualMode = enabled === true; - if (manualFields instanceof HTMLElement) { - manualFields.classList.toggle("hidden", !manualMode); - } - if (handshakePanel instanceof HTMLElement) { - handshakePanel.classList.toggle("hidden", manualMode); - } - if (manualToggle instanceof HTMLButtonElement) { - manualToggle.textContent = manualMode - ? "Back to QR pairing" - : "Paste a signer-provided bunker link instead"; - } - if (manualMode && manualInput instanceof HTMLTextAreaElement) { - manualInput.focus(); - } - if (!manualMode && manualInput instanceof HTMLTextAreaElement) { - manualInput.value = ""; - } - resetStatus(); - clearAuthChallenge(); - setError(""); - }; - - let submitHandler = null; let cancelHandler = null; let reuseHandler = null; let copyHandler = null; - let manualToggleHandler = null; let authOpenHandler = null; const cleanup = () => { - if (submitHandler) { - form.removeEventListener("submit", submitHandler); - } if (cancelHandler && cancelButton instanceof HTMLButtonElement) { cancelButton.removeEventListener("click", cancelHandler); } @@ -1455,9 +1405,6 @@ export default class LoginModalController { if (copyHandler && copyButton instanceof HTMLButtonElement) { copyButton.removeEventListener("click", copyHandler); } - if (manualToggleHandler && manualToggle instanceof HTMLButtonElement) { - manualToggle.removeEventListener("click", manualToggleHandler); - } if (authOpenHandler && authOpenButton instanceof HTMLButtonElement) { authOpenButton.removeEventListener("click", authOpenHandler); } @@ -1483,7 +1430,6 @@ export default class LoginModalController { resetStatus(); clearAuthChallenge(); - applyManualMode(false); setError(""); return new Promise((resolve) => { @@ -1506,42 +1452,12 @@ export default class LoginModalController { resolve(detail); }; - submitHandler = (event) => { - event.preventDefault(); + // Auto-submit immediately to generate the QR code + const autoStart = () => { setError(""); resetStatus(); clearAuthChallenge(); - const remember = - rememberCheckbox instanceof HTMLInputElement - ? rememberCheckbox.checked - : true; - - if (manualMode) { - if (!(manualInput instanceof HTMLTextAreaElement)) { - setError("Manual input is unavailable."); - return; - } - const raw = manualInput.value.trim(); - if (!raw) { - setError("Paste the bunker:// link from your signer."); - manualInput.focus(); - return; - } - setPendingState(true); - finish( - { - mode: "manual", - connectionString: raw, - remember: remember !== false, - onStatus: handleStatus, - onAuthUrl: handleAuthUrl, - }, - { keepMounted: false }, - ); - return; - } - const metadata = {}; if (this.document && typeof this.document.title === "string") { const title = this.document.title.trim(); @@ -1557,7 +1473,7 @@ export default class LoginModalController { finish( { mode: "handshake", - remember: remember !== false, + remember: true, // Always remember for now in simplified flow metadata, onHandshakePrepared: updateHandshakeDisplay, onStatus: handleStatus, @@ -1567,8 +1483,6 @@ export default class LoginModalController { ); }; - form.addEventListener("submit", submitHandler); - if (cancelButton instanceof HTMLButtonElement) { cancelHandler = (event) => { event.preventDefault(); @@ -1588,7 +1502,7 @@ export default class LoginModalController { if (copyButton instanceof HTMLButtonElement) { copyHandler = async (event) => { event.preventDefault(); - if (!(handshakeInput instanceof HTMLTextAreaElement)) { + if (!(handshakeInput instanceof HTMLInputElement)) { return; } const value = handshakeInput.value.trim(); @@ -1615,18 +1529,20 @@ export default class LoginModalController { } } catch (error) { copied = false; - } finally { - handshakeInput.setSelectionRange( - handshakeInput.value.length, - handshakeInput.value.length, - ); } } if (copied) { - setStatus("Connect URI copied to clipboard.", "success"); + // Temporary button feedback + const originalText = copyButton.textContent; + copyButton.textContent = "Copied!"; + copyButton.classList.add("btn-success"); // Assuming this utility exists or similar + setTimeout(() => { + copyButton.textContent = originalText; + copyButton.classList.remove("btn-success"); + }, 2000); } else { setStatus( - "Copy failed. Copy the link manually if needed.", + "Copy failed. Use the manual fallback below.", "warning", ); } @@ -1634,14 +1550,6 @@ export default class LoginModalController { copyButton.addEventListener("click", copyHandler); } - if (manualToggle instanceof HTMLButtonElement) { - manualToggleHandler = (event) => { - event.preventDefault(); - applyManualMode(!manualMode); - }; - manualToggle.addEventListener("click", manualToggleHandler); - } - if (authOpenButton instanceof HTMLButtonElement) { authOpenHandler = (event) => { event.preventDefault(); @@ -1656,6 +1564,9 @@ export default class LoginModalController { }; authOpenButton.addEventListener("click", authOpenHandler); } + + // Kick off the handshake generation + setTimeout(autoStart, 0); }); }