+
-
- Generating connect code…
+
+ Generating code...
-
-
- Connect URI
-
-
-
-
- Copy link
-
-
-
+ aria-hidden="true"
+ >
+
+
+ Copy Connect Link
+
+
+
+ Trouble scanning?
+
+
+
+
-
-
- Paste a signer-provided bunker link instead
-
-
-
- Signer link
-
-
-
- Use this fallback when your signer cannot scan QR codes.
-
-
-
-
-
-
- Remember this signer for future logins
-
+
+
- Reconnect using the saved session
+ Reconnect saved
-
-
Cancel
- Connect
+
+
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);
});
}