mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-31 18:02:27 +00:00
Refactor NIP-46 login UI to be cleaner and QR-first
- Simplified `components/login-modal.html` to center the QR code and use a "Copy Link" button instead of a large textarea. - Updated `js/ui/loginModalController.js` to support the new DOM structure and error handling. - Added `docs/nip46-roadmap.md` to document current state and future features.
This commit is contained in:
1 parent
fba936635e
commit
4196a6bb42
3 files changed
+121
-186
No files matched your search
+36
-64
@@ -338,8 +338,7 @@
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-lg font-semibold text-text">Pair a remote signer</h3>
|
||||
<p class="text-sm text-text-muted">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
@@ -347,50 +346,49 @@
|
||||
class="hidden rounded-lg border border-danger/60 bg-danger/10 px-4 py-2 text-sm text-danger"
|
||||
role="alert"
|
||||
></div>
|
||||
<div data-nip46-handshake-panel class="space-y-4">
|
||||
<div class="flex flex-col items-center gap-5 sm:items-stretch">
|
||||
<div data-nip46-handshake-panel class="space-y-6">
|
||||
<div class="flex flex-col items-center gap-6">
|
||||
<div
|
||||
class="flex aspect-square w-full max-w-sm items-center justify-center rounded-2xl border border-border-strong bg-white p-5 shadow-sm sm:max-w-md sm:p-8"
|
||||
class="flex aspect-square w-full max-w-[280px] items-center justify-center rounded-2xl border border-border-strong bg-white p-4 shadow-sm"
|
||||
data-nip46-qr
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="w-full text-center text-sm text-text-muted">
|
||||
Generating connect code…
|
||||
<div class="w-full text-center text-sm text-text-muted animate-pulse">
|
||||
Generating code...
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full space-y-3 sm:max-w-xl">
|
||||
<label class="block text-sm font-medium text-text" for="nip46HandshakeUri">
|
||||
Connect URI
|
||||
</label>
|
||||
<textarea
|
||||
id="nip46HandshakeUri"
|
||||
class="input"
|
||||
rows="3"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
|
||||
<div class="w-full space-y-4 text-center">
|
||||
<!-- Hidden input for copy functionality -->
|
||||
<input
|
||||
type="text"
|
||||
class="sr-only"
|
||||
data-nip46-handshake-uri
|
||||
readonly
|
||||
></textarea>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-ghost w-full text-sm sm:w-auto"
|
||||
data-nip46-copy-uri
|
||||
disabled
|
||||
>
|
||||
Copy link
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
data-nip46-secret
|
||||
class="hidden break-words text-xs text-text-muted"
|
||||
></p>
|
||||
aria-hidden="true"
|
||||
>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn w-full sm:max-w-xs"
|
||||
data-nip46-copy-uri
|
||||
disabled
|
||||
>
|
||||
Copy Connect Link
|
||||
</button>
|
||||
|
||||
<details class="text-sm text-text-muted">
|
||||
<summary class="cursor-pointer hover:text-text hover:underline">Trouble scanning?</summary>
|
||||
<div class="mt-2 p-2 bg-overlay-panel border border-border rounded-lg break-all text-xs font-mono text-left select-all" data-nip46-debug-uri>
|
||||
<!-- Raw URI injected here for debugging/manual copy -->
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-nip46-status
|
||||
class="hidden rounded-lg border border-info/40 bg-info/10 px-4 py-2 text-sm text-info"
|
||||
class="hidden rounded-lg border border-info/40 bg-info/10 px-4 py-3 text-sm text-info font-medium text-center"
|
||||
role="status"
|
||||
></div>
|
||||
<div
|
||||
@@ -403,45 +401,19 @@
|
||||
Open signer challenge
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<button type="button" class="btn-link text-sm" data-nip46-toggle-manual>
|
||||
Paste a signer-provided bunker link instead
|
||||
</button>
|
||||
<div class="hidden space-y-2" data-nip46-manual-fields>
|
||||
<label class="block text-sm font-medium text-text" for="nip46LegacyUri">
|
||||
Signer link
|
||||
</label>
|
||||
<textarea
|
||||
id="nip46LegacyUri"
|
||||
class="input"
|
||||
rows="2"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
placeholder="bunker://…"
|
||||
data-nip46-legacy-uri
|
||||
></textarea>
|
||||
<p class="text-xs text-text-muted">
|
||||
Use this fallback when your signer cannot scan QR codes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="inline-flex items-center gap-2 text-sm text-text">
|
||||
<input type="checkbox" class="checkbox" data-nip46-remember checked />
|
||||
<span>Remember this signer for future logins</span>
|
||||
</label>
|
||||
|
||||
<div class="flex justify-between items-center pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link text-sm hidden"
|
||||
data-nip46-reuse
|
||||
>
|
||||
Reconnect using the saved session
|
||||
Reconnect saved
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" class="btn-ghost" data-nip46-cancel>Cancel</button>
|
||||
<button type="submit" class="btn" data-nip46-submit>Connect</button>
|
||||
</div>
|
||||
<!-- Hidden submit to satisfy form semantics if needed, though we trigger via logic -->
|
||||
<button type="submit" class="hidden" data-nip46-submit></button>
|
||||
</form>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -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., `[email protected]`) 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]}*
|
||||
+33
-122
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user