mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-28 16:32:28 +00:00
Merge pull request #763 from PR0M3TH3AN/codex/implement-nip-46-auth-provider
Finalize NIP-46 remote signer integration
This commit is contained in:
7 files changed
+1979
-24
No files matched your search
@@ -58,13 +58,22 @@
|
||||
type="button"
|
||||
data-provider-button
|
||||
>
|
||||
<span class="flex flex-col items-start gap-2">
|
||||
<span class="flex w-full flex-col items-start gap-2">
|
||||
<span data-provider-label class="text-base font-semibold"></span>
|
||||
<span data-provider-description class="text-sm text-text-muted"></span>
|
||||
<span
|
||||
data-provider-status
|
||||
class="text-sm text-text-muted hidden"
|
||||
></span>
|
||||
<div
|
||||
data-provider-status-row
|
||||
class="hidden flex w-full items-center justify-between gap-2 text-sm text-text-muted"
|
||||
>
|
||||
<span data-provider-status class="truncate"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link text-sm hidden"
|
||||
data-provider-disconnect
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
<span
|
||||
data-provider-capabilities
|
||||
class="flex flex-wrap gap-2"
|
||||
@@ -175,6 +184,64 @@
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
<template data-login-nip46-dialog>
|
||||
<form
|
||||
data-nip46-form
|
||||
class="space-y-4 rounded-xl border border-border bg-overlay-panel-soft px-5 py-4"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-lg font-semibold text-text">Connect remote signer</h3>
|
||||
<p class="text-sm text-text-muted">
|
||||
Paste the <code>nostrconnect://</code> or <code>bunker://</code> link
|
||||
from your signer to establish a Nostr Connect session.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
data-nip46-error
|
||||
class="hidden rounded-lg border border-danger/60 bg-danger/10 px-4 py-2 text-sm text-danger"
|
||||
role="alert"
|
||||
></div>
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
class="block text-sm font-medium text-text"
|
||||
for="nip46ConnectUri"
|
||||
>
|
||||
Connection string
|
||||
</label>
|
||||
<textarea
|
||||
id="nip46ConnectUri"
|
||||
class="input"
|
||||
rows="3"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
placeholder="nostrconnect://… or bunker://…"
|
||||
data-nip46-connect-uri
|
||||
required
|
||||
></textarea>
|
||||
<p class="text-xs text-text-muted">
|
||||
We will connect to the relays embedded in the link and request signing
|
||||
permissions on your behalf.
|
||||
</p>
|
||||
</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>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link text-sm hidden"
|
||||
data-nip46-reuse
|
||||
>
|
||||
Reconnect using the saved session
|
||||
</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>
|
||||
</form>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,91 @@
|
||||
# NIP-46 Remote Signer Integration
|
||||
|
||||
bitvid can delegate event signing to a remote NIP-46 compatible signer. This
|
||||
flow lets operators keep long-lived keys on hardened devices while approving
|
||||
publish/edit/delete actions from a separate browser session.
|
||||
|
||||
## Connection strings
|
||||
|
||||
bitvid currently expects `bunker://` style URIs. They should embed the remote
|
||||
signer pubkey in the host segment and may include the following query
|
||||
parameters:
|
||||
|
||||
- `relay=` – one or more WSS relay URLs the signer watches for RPC requests.
|
||||
Repeat the parameter to advertise multiple relays.
|
||||
- `secret=` – optional shared secret that must echo back during the `connect`
|
||||
handshake.
|
||||
- `perms=` – optional permission string. When present bitvid forwards it during
|
||||
the initial `connect` RPC.
|
||||
- `name`, `url`, `image` – optional metadata shown in the login modal so users
|
||||
can distinguish between saved signers.
|
||||
|
||||
URIs without `relay=` parameters fall back to bitvid’s default relay bundle
|
||||
(`wss://relay.damus.io`, `wss://nos.lol`, `wss://relay.snort.social`,
|
||||
`wss://relay.primal.net`, `wss://relay.nostr.band`). Make sure your signer also
|
||||
publishes to at least one of these relays so both the browser and signer see the
|
||||
same NIP-46 frames.
|
||||
|
||||
## Relay requirements
|
||||
|
||||
The remote signer must subscribe to `kind 24133` events targeted at the client
|
||||
public key (included in the connection URI). bitvid publishes RPC requests to
|
||||
whichever relay list resolves from the URI + local fallback:
|
||||
|
||||
1. Explicit `relay=` parameters from the bunker URI (WSS only, duplicates
|
||||
removed and sanitized).
|
||||
2. The relays persisted in a previous session, if any.
|
||||
3. The default bitvid relay set listed above.
|
||||
|
||||
For reliable operation:
|
||||
|
||||
- Keep at least two relays online so publish attempts succeed within the
|
||||
8 second relay publish timeout.
|
||||
- Ensure every relay allows inbound events from both the browser’s IP and the
|
||||
remote signer. Corporate VPNs and Tor bridges frequently block public WSS
|
||||
relays.
|
||||
- Avoid private relays that block unsigned events from unknown clients; the
|
||||
browser session publishes unsigned requests that the signer decrypts with the
|
||||
shared secret.
|
||||
|
||||
## RPC expectations and timeouts
|
||||
|
||||
The RPC client issues the following methods:
|
||||
|
||||
- `connect` (optionally with `secret` and `perms`)
|
||||
- `get_public_key`
|
||||
- `sign_event` for publish/edit/delete flows
|
||||
|
||||
Each request is encrypted to the signer and published to the resolved relay set.
|
||||
bitvid waits up to **8 seconds** for a relay to accept the event and up to
|
||||
**15 seconds** for a response. `sign_event` calls allow up to
|
||||
**20 seconds** before timing out. On transient failures the client retries
|
||||
once (except when the signer returns an authentication URL).
|
||||
|
||||
If the signer responds with `error: auth_url`, the UI surfaces the provided URL
|
||||
so the user can complete any additional verification the signer requires.
|
||||
|
||||
## Session persistence and restoration
|
||||
|
||||
Successful connections write a session blob to
|
||||
`localStorage` under the key `bitvid:nip46:session:v1`. The payload includes the
|
||||
client key pair, remote pubkey, relay list, metadata, permissions, and the most
|
||||
recent user pubkey returned by the signer.
|
||||
|
||||
On page load bitvid reads the stored session, updates the login modal to show a
|
||||
saved signer, and silently attempts to reconnect in the background. If the
|
||||
attempt fails due to network timeouts or temporary relay outages the stored
|
||||
session remains intact so the user can retry manually. Fatal errors (for example
|
||||
shared secret mismatches) clear the saved session automatically. Users can also
|
||||
invoke “Disconnect” from the login modal to remove the stored credentials.
|
||||
|
||||
## Operational checklist
|
||||
|
||||
- Issue bunker URIs that list WSS relays reachable by both the signer and the
|
||||
browser session.
|
||||
- Keep the signer online and subscribed to `kind 24133` events for the advertised
|
||||
relays.
|
||||
- Respond to `connect`, `get_public_key`, and `sign_event` requests within the
|
||||
timeouts above.
|
||||
- Rotate secrets or permissions as needed—bitvid surfaces signer metadata so
|
||||
operators can confirm which device they are connecting to before approving new
|
||||
sessions.
|
||||
@@ -428,6 +428,7 @@ class Application {
|
||||
providers: authProviders,
|
||||
services: {
|
||||
authService: this.authService,
|
||||
nostrClient,
|
||||
},
|
||||
callbacks: {
|
||||
onProviderSelected: (providerId) => {
|
||||
|
||||
+1262
-12
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,5 @@
|
||||
import nip07 from "./nip07.js";
|
||||
import nip46 from "./nip46.js";
|
||||
import nsec from "./nsec.js";
|
||||
|
||||
function normalizeProviderId(provider, fallbackId) {
|
||||
@@ -37,7 +38,7 @@ function normalizeProviderMetadata(provider, fallbackId) {
|
||||
return Object.freeze({ id, label, description, badgeVariant });
|
||||
}
|
||||
|
||||
const providerList = [nip07, nsec].filter(Boolean);
|
||||
const providerList = [nip07, nip46, nsec].filter(Boolean);
|
||||
const providerMap = {};
|
||||
const metadataMap = {};
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
const PROVIDER_ID = "nip46";
|
||||
const PROVIDER_LABEL = "Remote signer";
|
||||
const PROVIDER_DESCRIPTION = "Connect to a NIP-46 compatible signer via Nostr Connect.";
|
||||
const PROVIDER_CAPABILITIES = Object.freeze([
|
||||
Object.freeze({
|
||||
id: "signing",
|
||||
label: "Signs events remotely",
|
||||
variant: "info",
|
||||
}),
|
||||
Object.freeze({
|
||||
id: "multi-device",
|
||||
label: "Works across browsers",
|
||||
variant: "neutral",
|
||||
}),
|
||||
]);
|
||||
const PROVIDER_BUTTON = Object.freeze({
|
||||
variant: "outline",
|
||||
});
|
||||
const PROVIDER_MESSAGES = Object.freeze({
|
||||
loading: "Connecting to remote signer…",
|
||||
slow: "Waiting for the signer to approve…",
|
||||
error: "Failed to connect to the remote signer. Please try again.",
|
||||
});
|
||||
|
||||
function normalizePubkey(result) {
|
||||
if (typeof result === "string") {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!result || typeof result !== "object") {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (typeof result.pubkey === "string") {
|
||||
return result.pubkey;
|
||||
}
|
||||
|
||||
if (typeof result.publicKey === "string") {
|
||||
return result.publicKey;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function normalizeOptions(options) {
|
||||
if (!options || typeof options !== "object") {
|
||||
return {
|
||||
connectionString: "",
|
||||
remember: true,
|
||||
reuseStored: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
connectionString:
|
||||
typeof options.connectionString === "string" ? options.connectionString.trim() : "",
|
||||
remember: options.remember !== false,
|
||||
reuseStored: options.reuseStored === true,
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
id: PROVIDER_ID,
|
||||
label: PROVIDER_LABEL,
|
||||
description: PROVIDER_DESCRIPTION,
|
||||
badgeVariant: "info",
|
||||
capabilities: PROVIDER_CAPABILITIES,
|
||||
button: PROVIDER_BUTTON,
|
||||
messages: PROVIDER_MESSAGES,
|
||||
errorMessage: PROVIDER_MESSAGES.error,
|
||||
async login({ nostrClient, options } = {}) {
|
||||
if (!nostrClient || typeof nostrClient !== "object") {
|
||||
const error = new Error("Remote signer login is not available.");
|
||||
error.code = "provider-unavailable";
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (typeof nostrClient.connectRemoteSigner !== "function") {
|
||||
const error = new Error("Remote signer support is unavailable in this environment.");
|
||||
error.code = "provider-unavailable";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalized = normalizeOptions(options);
|
||||
let result = null;
|
||||
|
||||
if (normalized.reuseStored) {
|
||||
if (typeof nostrClient.useStoredRemoteSigner !== "function") {
|
||||
const error = new Error("No stored remote signer is available on this device.");
|
||||
error.code = "no-stored-session";
|
||||
throw error;
|
||||
}
|
||||
|
||||
result = await nostrClient.useStoredRemoteSigner();
|
||||
} else {
|
||||
const uri = normalized.connectionString;
|
||||
if (!uri) {
|
||||
const error = new Error("A connection string is required to continue.");
|
||||
error.code = "connection-required";
|
||||
throw error;
|
||||
}
|
||||
|
||||
result = await nostrClient.connectRemoteSigner({
|
||||
connectionString: uri,
|
||||
remember: normalized.remember,
|
||||
});
|
||||
}
|
||||
|
||||
const pubkey = normalizePubkey(result);
|
||||
|
||||
return {
|
||||
authType: PROVIDER_ID,
|
||||
pubkey,
|
||||
signer: result && typeof result === "object" ? result.signer || null : null,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -202,6 +202,10 @@ export default class LoginModalController {
|
||||
services.authService && typeof services.authService === "object"
|
||||
? services.authService
|
||||
: null,
|
||||
nostrClient:
|
||||
services.nostrClient && typeof services.nostrClient === "object"
|
||||
? services.nostrClient
|
||||
: null,
|
||||
};
|
||||
|
||||
this.callbacks = {
|
||||
@@ -233,11 +237,18 @@ export default class LoginModalController {
|
||||
this.modalElement?.querySelector(".modal-body") || null;
|
||||
this.nsecTemplate =
|
||||
this.modalElement?.querySelector("template[data-login-nsec-dialog]") || null;
|
||||
this.nip46Template =
|
||||
this.modalElement?.querySelector("template[data-login-nip46-dialog]") || null;
|
||||
this.template = null;
|
||||
this.providerEntries = new Map();
|
||||
this.slowTimers = new Map();
|
||||
this.boundClickHandler = (event) => this.handleContainerClick(event);
|
||||
this.activeNsecForm = null;
|
||||
this.activeNip46Form = null;
|
||||
this.remoteSignerUnsubscribe = null;
|
||||
this.lastRemoteSignerStatus = null;
|
||||
|
||||
this.initializeRemoteSignerStatus();
|
||||
this.initialized = false;
|
||||
|
||||
this.initialize();
|
||||
@@ -260,11 +271,20 @@ export default class LoginModalController {
|
||||
|
||||
this.renderProviders();
|
||||
|
||||
if (typeof this.lastRemoteSignerStatus !== "undefined") {
|
||||
this.applyRemoteSignerStatus(this.lastRemoteSignerStatus);
|
||||
}
|
||||
|
||||
this.providerContainer.addEventListener("click", this.boundClickHandler);
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
getNostrClient() {
|
||||
const direct = this.services?.nostrClient;
|
||||
if (direct && typeof direct === "object") {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const service = this.services?.authService;
|
||||
if (!service || typeof service !== "object") {
|
||||
return null;
|
||||
@@ -288,6 +308,143 @@ export default class LoginModalController {
|
||||
return null;
|
||||
}
|
||||
|
||||
getStoredNip46Metadata() {
|
||||
const nostrClient = this.getNostrClient();
|
||||
if (!nostrClient || typeof nostrClient.getStoredNip46Metadata !== "function") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return nostrClient.getStoredNip46Metadata();
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[LoginModalController] Failed to inspect stored NIP-46 session:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
initializeRemoteSignerStatus() {
|
||||
const nostrClient = this.getNostrClient();
|
||||
if (!nostrClient) {
|
||||
this.applyRemoteSignerStatus(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof nostrClient.getRemoteSignerStatus === "function") {
|
||||
try {
|
||||
const status = nostrClient.getRemoteSignerStatus();
|
||||
this.applyRemoteSignerStatus(status);
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[LoginModalController] Failed to read initial remote signer status:",
|
||||
error,
|
||||
);
|
||||
this.applyRemoteSignerStatus(null);
|
||||
}
|
||||
} else {
|
||||
this.applyRemoteSignerStatus(null);
|
||||
}
|
||||
|
||||
if (typeof nostrClient.onRemoteSignerChange === "function") {
|
||||
try {
|
||||
this.remoteSignerUnsubscribe = nostrClient.onRemoteSignerChange((status) => {
|
||||
this.applyRemoteSignerStatus(status);
|
||||
});
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[LoginModalController] Failed to subscribe to remote signer updates:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
formatRemoteSignerLabel(status) {
|
||||
if (!status || typeof status !== "object") {
|
||||
return "remote signer";
|
||||
}
|
||||
|
||||
const { label, remoteNpub, remotePubkey } = status;
|
||||
if (typeof label === "string" && label.trim()) {
|
||||
return label.trim();
|
||||
}
|
||||
|
||||
if (typeof remoteNpub === "string" && remoteNpub.trim()) {
|
||||
return remoteNpub.trim();
|
||||
}
|
||||
|
||||
if (typeof remotePubkey === "string" && remotePubkey.trim()) {
|
||||
const trimmed = remotePubkey.trim();
|
||||
if (trimmed.length <= 16) {
|
||||
return trimmed;
|
||||
}
|
||||
return `${trimmed.slice(0, 8)}…${trimmed.slice(-6)}`;
|
||||
}
|
||||
|
||||
return "remote signer";
|
||||
}
|
||||
|
||||
applyRemoteSignerStatus(status) {
|
||||
this.lastRemoteSignerStatus = status || null;
|
||||
const storedMetadata = this.getStoredNip46Metadata();
|
||||
const hasStoredSession = storedMetadata?.hasSession === true;
|
||||
|
||||
const normalizedState =
|
||||
typeof status?.state === "string" && status.state.trim()
|
||||
? status.state.trim()
|
||||
: hasStoredSession
|
||||
? "stored"
|
||||
: "idle";
|
||||
|
||||
let message = "";
|
||||
let showDisconnect = false;
|
||||
|
||||
switch (normalizedState) {
|
||||
case "connected": {
|
||||
if (typeof status?.userNpub === "string" && status.userNpub.trim()) {
|
||||
message = `Connected as ${status.userNpub.trim()}`;
|
||||
} else {
|
||||
const label = this.formatRemoteSignerLabel(status);
|
||||
message = `Connected to ${label}`;
|
||||
}
|
||||
showDisconnect = true;
|
||||
break;
|
||||
}
|
||||
case "connecting": {
|
||||
message = "Connecting to remote signer…";
|
||||
break;
|
||||
}
|
||||
case "error": {
|
||||
message =
|
||||
typeof status?.message === "string" && status.message.trim()
|
||||
? status.message.trim()
|
||||
: "Remote signer unavailable.";
|
||||
break;
|
||||
}
|
||||
case "stored": {
|
||||
if (hasStoredSession) {
|
||||
const label = this.formatRemoteSignerLabel(status || storedMetadata);
|
||||
message = `Saved remote signer (${label}) ready to connect.`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
message = "";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.setProviderStatus("nip46", {
|
||||
message,
|
||||
reset: !message,
|
||||
showDisconnect,
|
||||
statusState: normalizedState,
|
||||
});
|
||||
}
|
||||
|
||||
async promptForNsecOptions() {
|
||||
if (!this.modalBody || !this.nsecTemplate) {
|
||||
userLogger.warn("[LoginModalController] Direct key login is unavailable in this build.");
|
||||
@@ -552,6 +709,160 @@ export default class LoginModalController {
|
||||
});
|
||||
}
|
||||
|
||||
async promptForNip46Options() {
|
||||
if (!this.modalBody || !this.nip46Template) {
|
||||
userLogger.warn(
|
||||
"[LoginModalController] Remote signer login is unavailable in this build.",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.activeNip46Form) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fragment = this.nip46Template.content
|
||||
? this.nip46Template.content.cloneNode(true)
|
||||
: null;
|
||||
if (!fragment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const form = fragment.querySelector("[data-nip46-form]");
|
||||
if (!(form instanceof HTMLFormElement)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const connectInput = form.querySelector("[data-nip46-connect-uri]");
|
||||
const rememberCheckbox = form.querySelector("[data-nip46-remember]");
|
||||
const reuseButton = form.querySelector("[data-nip46-reuse]");
|
||||
const cancelButton = form.querySelector("[data-nip46-cancel]");
|
||||
const errorNode = form.querySelector("[data-nip46-error]");
|
||||
|
||||
const storedMetadata = this.getStoredNip46Metadata();
|
||||
const hasStoredSession = storedMetadata?.hasSession === true;
|
||||
|
||||
if (reuseButton instanceof HTMLButtonElement) {
|
||||
reuseButton.classList.toggle("hidden", !hasStoredSession);
|
||||
}
|
||||
|
||||
const elementsToHide = [];
|
||||
for (const child of Array.from(this.modalBody.children)) {
|
||||
if (child instanceof HTMLElement && child !== form) {
|
||||
if (child.tagName === "TEMPLATE") {
|
||||
continue;
|
||||
}
|
||||
const wasHidden = child.classList.contains("hidden");
|
||||
if (!wasHidden) {
|
||||
child.classList.add("hidden");
|
||||
}
|
||||
elementsToHide.push({ element: child, wasHidden });
|
||||
}
|
||||
}
|
||||
|
||||
this.modalBody.appendChild(form);
|
||||
this.activeNip46Form = form;
|
||||
|
||||
const setError = (message) => {
|
||||
if (!(errorNode instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
const normalized = typeof message === "string" ? message.trim() : "";
|
||||
if (normalized) {
|
||||
errorNode.textContent = normalized;
|
||||
errorNode.classList.remove("hidden");
|
||||
} else {
|
||||
errorNode.textContent = "";
|
||||
errorNode.classList.add("hidden");
|
||||
}
|
||||
};
|
||||
|
||||
setError("");
|
||||
if (connectInput instanceof HTMLTextAreaElement) {
|
||||
connectInput.focus();
|
||||
}
|
||||
|
||||
let submitHandler = null;
|
||||
let cancelHandler = null;
|
||||
let reuseHandler = null;
|
||||
|
||||
const cleanup = () => {
|
||||
if (submitHandler) {
|
||||
form.removeEventListener("submit", submitHandler);
|
||||
}
|
||||
if (cancelHandler && cancelButton instanceof HTMLButtonElement) {
|
||||
cancelButton.removeEventListener("click", cancelHandler);
|
||||
}
|
||||
if (reuseHandler && reuseButton instanceof HTMLButtonElement) {
|
||||
reuseButton.removeEventListener("click", reuseHandler);
|
||||
}
|
||||
|
||||
if (form.parentElement) {
|
||||
form.parentElement.removeChild(form);
|
||||
}
|
||||
for (const entry of elementsToHide) {
|
||||
if (!(entry?.element instanceof HTMLElement)) {
|
||||
continue;
|
||||
}
|
||||
if (!entry.wasHidden) {
|
||||
entry.element.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
this.activeNip46Form = null;
|
||||
};
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (detail) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(detail);
|
||||
};
|
||||
|
||||
submitHandler = (event) => {
|
||||
event.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (!(connectInput instanceof HTMLTextAreaElement)) {
|
||||
setError("Enter the remote signer connection link to continue.");
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = connectInput.value.trim();
|
||||
if (!raw) {
|
||||
setError("Paste the nostrconnect:// or bunker:// link from your signer.");
|
||||
connectInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const remember =
|
||||
rememberCheckbox instanceof HTMLInputElement ? rememberCheckbox.checked : true;
|
||||
finish({ connectionString: raw, remember: remember !== false });
|
||||
};
|
||||
|
||||
form.addEventListener("submit", submitHandler);
|
||||
|
||||
if (cancelButton instanceof HTMLButtonElement) {
|
||||
cancelHandler = (event) => {
|
||||
event.preventDefault();
|
||||
finish(null);
|
||||
};
|
||||
cancelButton.addEventListener("click", cancelHandler);
|
||||
}
|
||||
|
||||
if (reuseButton instanceof HTMLButtonElement) {
|
||||
reuseHandler = (event) => {
|
||||
event.preventDefault();
|
||||
finish({ reuseStored: true });
|
||||
};
|
||||
reuseButton.addEventListener("click", reuseHandler);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
resolveTemplate() {
|
||||
if (!this.modalElement) {
|
||||
return null;
|
||||
@@ -660,6 +971,17 @@ export default class LoginModalController {
|
||||
statusNode.classList.add("hidden");
|
||||
}
|
||||
|
||||
const statusRow = button.querySelector("[data-provider-status-row]");
|
||||
if (statusRow instanceof HTMLElement) {
|
||||
statusRow.classList.add("hidden");
|
||||
}
|
||||
|
||||
const disconnectButton = button.querySelector("[data-provider-disconnect]");
|
||||
if (disconnectButton instanceof HTMLButtonElement) {
|
||||
disconnectButton.dataset.providerId = provider.id;
|
||||
disconnectButton.classList.add("hidden");
|
||||
}
|
||||
|
||||
const capabilitiesNode = button.querySelector(
|
||||
"[data-provider-capabilities]",
|
||||
);
|
||||
@@ -702,6 +1024,9 @@ export default class LoginModalController {
|
||||
button,
|
||||
descriptionNode: descriptionNode instanceof HTMLElement ? descriptionNode : null,
|
||||
statusNode: statusNode instanceof HTMLElement ? statusNode : null,
|
||||
statusRow: statusRow instanceof HTMLElement ? statusRow : null,
|
||||
disconnectButton:
|
||||
disconnectButton instanceof HTMLButtonElement ? disconnectButton : null,
|
||||
defaultDescription: provider.description,
|
||||
},
|
||||
};
|
||||
@@ -713,6 +1038,17 @@ export default class LoginModalController {
|
||||
return;
|
||||
}
|
||||
|
||||
const disconnectButton = target.closest("[data-provider-disconnect]");
|
||||
if (disconnectButton instanceof HTMLButtonElement) {
|
||||
const providerId = disconnectButton.dataset.providerId;
|
||||
if (providerId) {
|
||||
this.handleProviderDisconnect(providerId);
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
const button = target.closest("[data-provider-button]");
|
||||
if (!(button instanceof HTMLButtonElement)) {
|
||||
return;
|
||||
@@ -726,10 +1062,60 @@ export default class LoginModalController {
|
||||
this.handleProviderSelection(providerId);
|
||||
}
|
||||
|
||||
async handleProviderDisconnect(providerId) {
|
||||
if (providerId !== "nip46") {
|
||||
return;
|
||||
}
|
||||
|
||||
const nostrClient = this.getNostrClient();
|
||||
if (!nostrClient || typeof nostrClient.disconnectRemoteSigner !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await nostrClient.disconnectRemoteSigner({ keepStored: false });
|
||||
} catch (error) {
|
||||
devLogger.warn("[LoginModalController] Failed to disconnect remote signer:", error);
|
||||
}
|
||||
}
|
||||
|
||||
getProviderEntry(providerId) {
|
||||
return this.providerEntries.get(providerId) || null;
|
||||
}
|
||||
|
||||
setProviderStatus(providerId, options = {}) {
|
||||
const entry = this.getProviderEntry(providerId);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
message = "",
|
||||
reset = false,
|
||||
showDisconnect = false,
|
||||
disableDisconnect = false,
|
||||
statusState = null,
|
||||
} = options || {};
|
||||
|
||||
if (statusState) {
|
||||
entry.statusState = statusState;
|
||||
} else {
|
||||
delete entry.statusState;
|
||||
}
|
||||
|
||||
this.updateStatusMessage(entry, message, reset);
|
||||
|
||||
const shouldShowRow = !reset && typeof message === "string" && message.trim();
|
||||
if (entry.statusRow instanceof HTMLElement) {
|
||||
entry.statusRow.classList.toggle("hidden", !shouldShowRow);
|
||||
}
|
||||
|
||||
if (entry.disconnectButton instanceof HTMLButtonElement) {
|
||||
entry.disconnectButton.classList.toggle("hidden", !showDisconnect);
|
||||
entry.disconnectButton.disabled = !!disableDisconnect;
|
||||
}
|
||||
}
|
||||
|
||||
setLoadingState(providerId, isLoading) {
|
||||
const entry = this.getProviderEntry(providerId);
|
||||
if (!entry) {
|
||||
@@ -741,10 +1127,14 @@ export default class LoginModalController {
|
||||
entry.button.dataset.state = "loading";
|
||||
entry.button.setAttribute("aria-busy", "true");
|
||||
|
||||
this.updateStatusMessage(
|
||||
entry,
|
||||
entry.provider.messages.loading || "Connecting…",
|
||||
);
|
||||
const loadingMessage = entry.provider.messages.loading || "Connecting…";
|
||||
this.setProviderStatus(providerId, {
|
||||
message: loadingMessage,
|
||||
reset: false,
|
||||
showDisconnect: false,
|
||||
disableDisconnect: true,
|
||||
statusState: "loading",
|
||||
});
|
||||
this.startSlowTimer(providerId, entry);
|
||||
} else {
|
||||
const shouldRemainDisabled = entry.provider.disabled === true;
|
||||
@@ -758,7 +1148,7 @@ export default class LoginModalController {
|
||||
delete entry.button.dataset.state;
|
||||
entry.button.removeAttribute("aria-busy");
|
||||
this.stopSlowTimer(providerId);
|
||||
this.updateStatusMessage(entry, "", true);
|
||||
this.setProviderStatus(providerId, { message: "", reset: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -812,7 +1202,13 @@ export default class LoginModalController {
|
||||
|
||||
const slowMessage =
|
||||
entry.provider.messages.slow || "Waiting for the provider…";
|
||||
this.updateStatusMessage(entry, slowMessage);
|
||||
this.setProviderStatus(providerId, {
|
||||
message: slowMessage,
|
||||
reset: false,
|
||||
showDisconnect: false,
|
||||
statusState: "waiting",
|
||||
disableDisconnect: true,
|
||||
});
|
||||
}, timeout);
|
||||
|
||||
this.slowTimers.set(providerId, timerId);
|
||||
@@ -877,6 +1273,26 @@ export default class LoginModalController {
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else if (entry.provider.id === "nip46") {
|
||||
try {
|
||||
const nip46Options = await this.promptForNip46Options();
|
||||
if (!nip46Options) {
|
||||
devLogger.log(
|
||||
"[LoginModalController] Remote signer login cancelled by the user.",
|
||||
);
|
||||
if (entry.button instanceof HTMLButtonElement) {
|
||||
entry.button.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
providerOptions = nip46Options;
|
||||
} catch (promptError) {
|
||||
devLogger.error(
|
||||
"[LoginModalController] Failed to collect remote signer connection:",
|
||||
promptError,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.setLoadingState(providerId, true);
|
||||
@@ -942,6 +1358,18 @@ export default class LoginModalController {
|
||||
this.stopSlowTimer(providerId);
|
||||
}
|
||||
|
||||
if (typeof this.remoteSignerUnsubscribe === "function") {
|
||||
try {
|
||||
this.remoteSignerUnsubscribe();
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[LoginModalController] Remote signer unsubscribe handler threw:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
this.remoteSignerUnsubscribe = null;
|
||||
|
||||
this.providerEntries.clear();
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user