Files
bitvid/js/ui/loginModalController.js
T

1841 lines
53 KiB
JavaScript

import { devLogger, userLogger } from "../utils/logger.js";
import { renderQrCode } from "../utils/qrcode.js";
const noop = () => {};
const SLOW_PROVIDER_DELAY_MS = 8_000;
function toArray(input) {
if (!input) {
return [];
}
if (Array.isArray(input)) {
return input;
}
if (typeof input === "object") {
return Object.values(input);
}
return [];
}
function normalizeProvider(provider, fallbackId, index) {
if (!provider || typeof provider !== "object") {
return null;
}
if (typeof provider.login !== "function") {
return null;
}
const rawId =
typeof provider.id === "string" && provider.id.trim()
? provider.id.trim()
: typeof fallbackId === "string" && fallbackId
? `${fallbackId}-${index}`
: `provider-${index}`;
const label =
typeof provider.label === "string" && provider.label.trim()
? provider.label.trim()
: rawId;
const eyebrow =
typeof provider.eyebrow === "string" && provider.eyebrow.trim()
? provider.eyebrow.trim()
: "";
const description =
typeof provider.description === "string" && provider.description.trim()
? provider.description.trim()
: "";
const rawTone =
typeof provider.tone === "string" && provider.tone.trim()
? provider.tone.trim().toLowerCase()
: "";
const tone = rawTone ? rawTone : "";
const button = provider.button && typeof provider.button === "object"
? provider.button
: {};
const messages = provider.messages && typeof provider.messages === "object"
? provider.messages
: {};
const capabilities = Array.isArray(provider.capabilities)
? provider.capabilities.filter((capability) =>
capability && typeof capability === "object" && capability.label,
)
: [];
const orderValue = Number.parseInt(provider.order, 10);
const order = Number.isFinite(orderValue) ? orderValue : Number.MAX_SAFE_INTEGER;
return {
id: rawId,
source: provider,
label,
eyebrow,
description,
button,
messages,
capabilities,
order,
tone,
disabled: provider.disabled === true || provider.available === false,
errorMessage:
typeof provider.errorMessage === "string" && provider.errorMessage.trim()
? provider.errorMessage.trim()
: typeof messages.error === "string" && messages.error.trim()
? messages.error.trim()
: "Failed to login. Please try again.",
};
}
function sortProviders(providers) {
return providers.sort((a, b) => {
if (a.order !== b.order) {
return a.order - b.order;
}
return a.label.localeCompare(b.label);
});
}
function safeInvoke(callback, payload) {
if (typeof callback !== "function") {
return undefined;
}
try {
return callback(payload);
} catch (error) {
devLogger.warn("[LoginModalController] Callback threw:", error);
return undefined;
}
}
async function safeInvokeAsync(callback, payload) {
if (typeof callback !== "function") {
return;
}
try {
await callback(payload);
} catch (error) {
devLogger.warn("[LoginModalController] Async callback threw:", error);
}
}
function resolveDocument(candidate) {
if (candidate && typeof candidate === "object" && candidate.nodeType === 9) {
return candidate;
}
if (typeof document !== "undefined" && document?.nodeType === 9) {
return document;
}
return null;
}
function resolveWindow(doc) {
if (doc && typeof doc.defaultView !== "undefined") {
return doc.defaultView;
}
if (typeof window !== "undefined") {
return window;
}
return null;
}
function resolveButtonVariantClasses(variant, className) {
const classes = new Set(["login-option"]);
if (typeof className === "string" && className.trim()) {
className
.trim()
.split(/\s+/)
.forEach((token) => {
if (token) {
classes.add(token);
}
});
}
switch (variant) {
case "ghost":
classes.add("login-option--ghost");
break;
case "outline":
classes.add("login-option--outline");
break;
case "link":
classes.add("login-option--link");
break;
case "primary":
case "default":
default:
classes.add("login-option--primary");
break;
}
return Array.from(classes).join(" ");
}
export default class LoginModalController {
constructor({
modalElement,
providers,
services = {},
callbacks = {},
helpers = {},
document: providedDocument,
} = {}) {
this.modalElement =
modalElement instanceof HTMLElement ? modalElement : null;
this.document =
resolveDocument(providedDocument || this.modalElement?.ownerDocument) || null;
this.window = resolveWindow(this.document);
const normalizedProviders = sortProviders(
toArray(providers).reduce((accumulator, provider, index) => {
const normalized = normalizeProvider(provider, "provider", index);
if (normalized) {
accumulator.push(normalized);
}
return accumulator;
}, []),
);
this.providers = normalizedProviders;
this.services = {
authService:
services.authService && typeof services.authService === "object"
? services.authService
: null,
nostrClient:
services.nostrClient && typeof services.nostrClient === "object"
? services.nostrClient
: null,
};
this.callbacks = {
onProviderSelected:
typeof callbacks.onProviderSelected === "function"
? callbacks.onProviderSelected
: noop,
onLoginSuccess:
typeof callbacks.onLoginSuccess === "function"
? callbacks.onLoginSuccess
: null,
onLoginError:
typeof callbacks.onLoginError === "function"
? callbacks.onLoginError
: null,
};
this.helpers = {
closeModal:
typeof helpers.closeModal === "function" ? helpers.closeModal : noop,
describeLoginError:
typeof helpers.describeLoginError === "function"
? helpers.describeLoginError
: (_, fallbackMessage) => fallbackMessage,
};
this.providerContainer = null;
this.modalBody =
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.pendingNip46Cleanup = null;
this.remoteSignerUnsubscribe = null;
this.lastRemoteSignerStatus = null;
this.nextRequestLoginOptions = null;
this.nextRequestLoginOptionsResolver = null;
this.initializeRemoteSignerStatus();
this.initialized = false;
this.initialize();
}
initialize() {
if (!this.modalElement) {
devLogger.warn("[LoginModalController] Modal element was not provided.");
return;
}
const container = this.modalElement.querySelector("[data-login-providers]");
if (!(container instanceof HTMLElement)) {
devLogger.warn("[LoginModalController] Provider container not found.");
return;
}
this.providerContainer = container;
this.template = this.resolveTemplate();
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;
}
return service.nostrClient || null;
}
getStoredNsecMetadata() {
const nostrClient = this.getNostrClient();
if (!nostrClient || typeof nostrClient.getStoredSessionActorMetadata !== "function") {
return null;
}
try {
return nostrClient.getStoredSessionActorMetadata();
} catch (error) {
devLogger.warn("[LoginModalController] Failed to inspect stored session actor:", error);
}
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.");
return null;
}
if (this.activeNsecForm) {
return null;
}
const fragment = this.nsecTemplate.content
? this.nsecTemplate.content.cloneNode(true)
: null;
if (!fragment) {
return null;
}
const form = fragment.querySelector("[data-nsec-form]");
if (!(form instanceof HTMLFormElement)) {
return null;
}
const unlockSection = form.querySelector("[data-nsec-unlock-section]");
const unlockRadio = form.querySelector('[data-nsec-mode="unlock"]');
const importRadio = form.querySelector('[data-nsec-mode="import"]');
const unlockPassphrase = form.querySelector("[data-nsec-unlock-passphrase]");
const importFields = form.querySelector("[data-nsec-import-fields]");
const secretField = form.querySelector("[data-nsec-secret]");
const rememberCheckbox = form.querySelector("[data-nsec-remember]");
const passphraseFields = form.querySelector("[data-nsec-passphrase-fields]");
const passphraseInput = form.querySelector("[data-nsec-passphrase]");
const passphraseConfirm = form.querySelector("[data-nsec-passphrase-confirm]");
const cancelButton = form.querySelector("[data-nsec-cancel]");
const errorNode = form.querySelector("[data-nsec-error]");
const storedMetadata = this.getStoredNsecMetadata();
const hasEncryptedStored = storedMetadata?.hasEncryptedKey === true;
if (unlockSection instanceof HTMLElement) {
unlockSection.classList.toggle("hidden", !hasEncryptedStored);
}
if (unlockRadio instanceof HTMLInputElement) {
unlockRadio.checked = hasEncryptedStored;
}
if (importRadio instanceof HTMLInputElement) {
importRadio.checked = !hasEncryptedStored;
}
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.activeNsecForm = 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");
}
};
const updatePassphraseVisibility = () => {
if (!(passphraseFields instanceof HTMLElement)) {
return;
}
const shouldShow = !!(rememberCheckbox instanceof HTMLInputElement && rememberCheckbox.checked);
passphraseFields.hidden = !shouldShow;
if (passphraseInput instanceof HTMLInputElement) {
passphraseInput.disabled = !shouldShow;
passphraseInput.required = shouldShow;
if (!shouldShow) {
passphraseInput.value = "";
}
}
if (passphraseConfirm instanceof HTMLInputElement) {
passphraseConfirm.disabled = !shouldShow;
passphraseConfirm.required = shouldShow;
if (!shouldShow) {
passphraseConfirm.value = "";
}
}
};
const updateMode = () => {
const usingUnlock = unlockRadio instanceof HTMLInputElement && unlockRadio.checked;
if (importFields instanceof HTMLElement) {
importFields.classList.toggle("hidden", usingUnlock);
}
if (unlockPassphrase instanceof HTMLInputElement) {
unlockPassphrase.disabled = !usingUnlock;
unlockPassphrase.required = usingUnlock;
if (!usingUnlock) {
unlockPassphrase.value = "";
}
}
if (secretField instanceof HTMLTextAreaElement) {
secretField.disabled = usingUnlock;
secretField.required = !usingUnlock;
if (usingUnlock) {
secretField.value = "";
}
}
if (rememberCheckbox instanceof HTMLInputElement) {
rememberCheckbox.disabled = usingUnlock;
if (usingUnlock) {
rememberCheckbox.checked = false;
}
}
updatePassphraseVisibility();
if (usingUnlock) {
if (unlockPassphrase instanceof HTMLInputElement) {
unlockPassphrase.focus();
}
} else if (secretField instanceof HTMLTextAreaElement) {
secretField.focus();
}
};
updatePassphraseVisibility();
updateMode();
setError("");
const handleModeChange = () => {
setError("");
updateMode();
};
if (unlockRadio instanceof HTMLInputElement) {
unlockRadio.addEventListener("change", handleModeChange);
}
if (importRadio instanceof HTMLInputElement) {
importRadio.addEventListener("change", handleModeChange);
}
const handleRememberChange = () => {
setError("");
updatePassphraseVisibility();
};
if (rememberCheckbox instanceof HTMLInputElement) {
rememberCheckbox.addEventListener("change", handleRememberChange);
}
const cleanup = () => {
if (unlockRadio instanceof HTMLInputElement) {
unlockRadio.removeEventListener("change", handleModeChange);
}
if (importRadio instanceof HTMLInputElement) {
importRadio.removeEventListener("change", handleModeChange);
}
if (rememberCheckbox instanceof HTMLInputElement) {
rememberCheckbox.removeEventListener("change", handleRememberChange);
}
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.activeNsecForm = null;
};
return new Promise((resolve) => {
let settled = false;
const finish = (detail) => {
if (settled) {
return;
}
settled = true;
cleanup();
resolve(detail);
};
form.addEventListener("submit", (event) => {
event.preventDefault();
setError("");
const usingUnlock = unlockRadio instanceof HTMLInputElement && unlockRadio.checked && hasEncryptedStored;
if (usingUnlock) {
if (!(unlockPassphrase instanceof HTMLInputElement) || !unlockPassphrase.value.trim()) {
setError("Enter the passphrase to unlock your stored key.");
if (unlockPassphrase instanceof HTMLInputElement) {
unlockPassphrase.focus();
}
return;
}
finish({ unlockStored: true, passphrase: unlockPassphrase.value });
return;
}
if (!(secretField instanceof HTMLTextAreaElement)) {
setError("A private key is required to continue.");
return;
}
const secret = secretField.value.trim();
if (!secret) {
setError("Paste an nsec, hex key, or mnemonic to continue.");
secretField.focus();
return;
}
const remember = rememberCheckbox instanceof HTMLInputElement && rememberCheckbox.checked;
let passphrase = "";
if (remember) {
if (!(passphraseInput instanceof HTMLInputElement) || !(passphraseConfirm instanceof HTMLInputElement)) {
setError("A passphrase is required to remember this key.");
return;
}
const pass = passphraseInput.value;
const confirm = passphraseConfirm.value;
if (!pass || !confirm) {
setError("Enter and confirm a passphrase to encrypt your key.");
passphraseInput.focus();
return;
}
if (pass !== confirm) {
setError("Passphrases do not match.");
passphraseConfirm.focus();
return;
}
passphrase = pass;
}
finish(
remember
? { secret, persist: true, passphrase }
: { secret, persist: false }
);
});
if (cancelButton instanceof HTMLButtonElement) {
cancelButton.addEventListener("click", () => finish(null), { once: true });
}
});
}
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 handshakePanel = form.querySelector("[data-nip46-handshake-panel]");
const qrContainer = form.querySelector("[data-nip46-qr]");
const handshakeInput = form.querySelector("[data-nip46-handshake-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();
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;
if (handshakeInput instanceof HTMLTextAreaElement) {
handshakeInput.readOnly = true;
handshakeInput.value = "";
handshakeInput.placeholder = "Waiting for connect link…";
}
if (copyButton instanceof HTMLButtonElement) {
copyButton.disabled = true;
}
const statusClassMap = {
info: ["border-info/40", "bg-info/10", "text-info"],
success: ["border-success/40", "bg-success/10", "text-success"],
warning: ["border-warning/50", "bg-warning/10", "text-warning"],
danger: ["border-danger/60", "bg-danger/10", "text-danger"],
};
const statusClassValues = new Set(
Object.values(statusClassMap).flat(),
);
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");
}
};
const setStatus = (message, variant = "info") => {
if (!(statusNode instanceof HTMLElement)) {
return;
}
const normalized = typeof message === "string" ? message.trim() : "";
if (!normalized) {
statusNode.textContent = "";
statusNode.classList.add("hidden");
for (const cls of statusClassValues) {
statusNode.classList.remove(cls);
}
return;
}
statusNode.textContent = normalized;
statusNode.classList.remove("hidden");
for (const cls of statusClassValues) {
statusNode.classList.remove(cls);
}
const classes = statusClassMap[variant] || statusClassMap.info;
statusNode.classList.add(...classes);
};
const resetStatus = () => setStatus("", "info");
let qrInstance = null;
let manualMode = false;
let pendingAuthUrl = "";
const setAuthChallenge = (url) => {
pendingAuthUrl = typeof url === "string" ? url.trim() : "";
if (!(authContainer instanceof HTMLElement)) {
return;
}
if (!pendingAuthUrl) {
authContainer.classList.add("hidden");
if (authMessageNode instanceof HTMLElement) {
authMessageNode.textContent = "";
}
return;
}
authContainer.classList.remove("hidden");
if (authMessageNode instanceof HTMLElement) {
authMessageNode.textContent =
"The signer requested additional authentication.";
}
};
const clearAuthChallenge = () => {
setAuthChallenge("");
};
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;
}
if (cancelButton instanceof HTMLButtonElement) {
cancelButton.disabled = disabled;
}
if (copyButton instanceof HTMLButtonElement) {
const hasUri =
handshakeInput instanceof HTMLTextAreaElement &&
!!handshakeInput.value;
copyButton.disabled = disabled || !hasUri;
}
};
const updateHandshakeDisplay = (detail) => {
const uri =
detail && typeof detail === "object"
? detail.uri || detail.connectionString || ""
: "";
if (handshakeInput instanceof HTMLTextAreaElement) {
handshakeInput.value = 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 = "";
if (uri) {
try {
qrInstance = renderQrCode(qrContainer, uri, {
width: 256,
height: 256,
});
} catch (error) {
qrInstance = null;
devLogger.warn(
"[LoginModalController] Failed to render NIP-46 QR code:",
error,
);
const fallback = this.document?.createElement("div");
if (fallback) {
fallback.className = "text-center text-xs text-text-muted";
fallback.textContent =
"Unable to render QR code. Use the connect link below.";
qrContainer.appendChild(fallback);
}
}
} else {
qrInstance = null;
}
}
setPendingState(false);
if (submitButton instanceof HTMLButtonElement) {
submitButton.disabled = true;
}
resetStatus();
clearAuthChallenge();
setError("");
};
const handleStatus = (status) => {
if (!status || typeof status !== "object") {
resetStatus();
return;
}
const message =
typeof status.message === "string" ? status.message.trim() : "";
if (!message) {
resetStatus();
return;
}
let variant =
typeof status.variant === "string" && status.variant.trim()
? status.variant.trim().toLowerCase()
: "";
if (!variant) {
if (status.phase === "auth") {
variant = "warning";
} else if (
status.phase === "connected" ||
status.state === "ready" ||
status.state === "acknowledged"
) {
variant = "success";
} else {
variant = "info";
}
}
setStatus(message, variant);
};
const handleAuthUrl = (url) => {
const normalized = typeof url === "string" ? url.trim() : "";
if (!normalized) {
clearAuthChallenge();
return;
}
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);
}
if (reuseHandler && reuseButton instanceof HTMLButtonElement) {
reuseButton.removeEventListener("click", reuseHandler);
}
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);
}
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;
this.pendingNip46Cleanup = null;
qrInstance = null;
clearAuthChallenge();
resetStatus();
setError("");
};
resetStatus();
clearAuthChallenge();
applyManualMode(false);
setError("");
return new Promise((resolve) => {
let settled = false;
const finish = (detail, { keepMounted = false } = {}) => {
if (settled) {
return;
}
settled = true;
if (keepMounted) {
this.pendingNip46Cleanup = () => {
cleanup();
};
} else {
cleanup();
}
resolve(detail);
};
submitHandler = (event) => {
event.preventDefault();
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();
if (title) {
metadata.name = title;
}
}
if (this.window?.location?.origin) {
metadata.url = this.window.location.origin;
}
setPendingState(true);
finish(
{
mode: "handshake",
remember: remember !== false,
metadata,
onHandshakePrepared: updateHandshakeDisplay,
onStatus: handleStatus,
onAuthUrl: handleAuthUrl,
},
{ keepMounted: true },
);
};
form.addEventListener("submit", submitHandler);
if (cancelButton instanceof HTMLButtonElement) {
cancelHandler = (event) => {
event.preventDefault();
finish(null, { keepMounted: false });
};
cancelButton.addEventListener("click", cancelHandler);
}
if (reuseButton instanceof HTMLButtonElement) {
reuseHandler = (event) => {
event.preventDefault();
finish({ reuseStored: true }, { keepMounted: false });
};
reuseButton.addEventListener("click", reuseHandler);
}
if (copyButton instanceof HTMLButtonElement) {
copyHandler = async (event) => {
event.preventDefault();
if (!(handshakeInput instanceof HTMLTextAreaElement)) {
return;
}
const value = handshakeInput.value.trim();
if (!value) {
return;
}
let copied = false;
if (
typeof navigator !== "undefined" &&
navigator?.clipboard?.writeText
) {
try {
await navigator.clipboard.writeText(value);
copied = true;
} catch (error) {
copied = false;
}
}
if (!copied && handshakeInput.select) {
try {
handshakeInput.select();
if (this.document?.execCommand) {
copied = this.document.execCommand("copy");
}
} catch (error) {
copied = false;
} finally {
handshakeInput.setSelectionRange(
handshakeInput.value.length,
handshakeInput.value.length,
);
}
}
if (copied) {
setStatus("Connect URI copied to clipboard.", "success");
} else {
setStatus(
"Copy failed. Copy the link manually if needed.",
"warning",
);
}
};
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();
if (!pendingAuthUrl) {
return;
}
if (this.window && typeof this.window.open === "function") {
this.window.open(pendingAuthUrl, "_blank", "noopener,noreferrer");
} else if (this.document && this.document.location) {
this.document.location.href = pendingAuthUrl;
}
};
authOpenButton.addEventListener("click", authOpenHandler);
}
});
}
resolveTemplate() {
if (!this.modalElement) {
return null;
}
const template = this.modalElement.querySelector(
"template[data-login-provider-template]",
);
if (template instanceof HTMLTemplateElement) {
return template;
}
return null;
}
createFragment() {
if (this.template) {
return this.template.content
? this.template.content.cloneNode(true)
: this.document?.createDocumentFragment() || null;
}
if (!this.document) {
return null;
}
const fragment = this.document.createDocumentFragment();
const button = this.document.createElement("button");
button.type = "button";
button.className = "btn w-full text-left";
button.dataset.providerButton = "";
fragment.appendChild(button);
return fragment;
}
renderProviders() {
if (!this.providerContainer) {
return;
}
this.providerContainer.innerHTML = "";
this.providerEntries.clear();
if (!this.providers.length) {
userLogger.warn(
"[LoginModalController] No authentication providers available to render.",
);
return;
}
for (const provider of this.providers) {
const fragment = this.createFragment();
if (!fragment) {
continue;
}
const appended = this.appendProviderFragment(fragment, provider);
if (!appended) {
continue;
}
this.providerContainer.appendChild(appended.fragment);
this.providerEntries.set(provider.id, appended.entry);
}
}
appendProviderFragment(fragment, provider) {
const button = fragment.querySelector("[data-provider-button]");
if (!(button instanceof HTMLButtonElement)) {
devLogger.warn(
`[LoginModalController] Provider template missing button for ${provider.id}.`,
);
return null;
}
button.type = "button";
button.dataset.providerId = provider.id;
button.className = resolveButtonVariantClasses(
provider.button?.variant,
provider.button?.className,
);
if (provider.tone) {
button.dataset.tone = provider.tone;
} else {
delete button.dataset.tone;
button.removeAttribute("data-tone");
}
button.disabled = provider.disabled;
if (provider.disabled) {
button.setAttribute("aria-disabled", "true");
} else {
button.removeAttribute("aria-disabled");
}
const eyebrowNode = button.querySelector("[data-provider-eyebrow]");
if (eyebrowNode instanceof HTMLElement) {
eyebrowNode.textContent = provider.eyebrow || "";
eyebrowNode.classList.toggle("hidden", !provider.eyebrow);
}
const labelNode = button.querySelector("[data-provider-label]");
if (labelNode instanceof HTMLElement) {
labelNode.textContent = provider.label;
} else {
button.textContent = provider.label;
}
const descriptionNode = button.querySelector("[data-provider-description]");
if (descriptionNode instanceof HTMLElement) {
descriptionNode.textContent = provider.description;
descriptionNode.classList.toggle("hidden", !provider.description);
}
const statusNode = button.querySelector("[data-provider-status]");
if (statusNode instanceof HTMLElement) {
statusNode.textContent = "";
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]",
);
if (capabilitiesNode instanceof HTMLElement) {
capabilitiesNode.innerHTML = "";
if (provider.capabilities.length) {
capabilitiesNode.classList.remove("hidden");
for (const capability of provider.capabilities) {
const badgeLabel =
typeof capability.label === "string" && capability.label.trim()
? capability.label.trim()
: "";
if (!badgeLabel) {
continue;
}
const badge = this.document?.createElement("span");
if (!badge) {
continue;
}
badge.className = "badge";
if (
capability.variant &&
typeof capability.variant === "string" &&
capability.variant.trim()
) {
badge.dataset.variant = capability.variant.trim();
}
badge.textContent = badgeLabel;
capabilitiesNode.appendChild(badge);
}
} else {
capabilitiesNode.classList.add("hidden");
}
}
return {
fragment,
entry: {
provider,
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,
},
};
}
handleContainerClick(event) {
const target = event.target;
if (!(target instanceof Element)) {
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;
}
const providerId = button.dataset.providerId;
if (!providerId) {
return;
}
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) {
return;
}
if (isLoading) {
entry.button.disabled = true;
entry.button.dataset.state = "loading";
entry.button.setAttribute("aria-busy", "true");
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;
entry.button.disabled = shouldRemainDisabled;
if (shouldRemainDisabled) {
entry.button.setAttribute("aria-disabled", "true");
} else {
entry.button.removeAttribute("aria-disabled");
}
delete entry.button.dataset.state;
entry.button.removeAttribute("aria-busy");
this.stopSlowTimer(providerId);
this.setProviderStatus(providerId, { message: "", reset: true });
}
}
updateStatusMessage(entry, message, reset = false) {
if (!entry) {
return;
}
const normalized = typeof message === "string" ? message.trim() : "";
if (entry.statusNode) {
if (normalized && !reset) {
entry.statusNode.textContent = normalized;
entry.statusNode.classList.remove("hidden");
} else {
entry.statusNode.textContent = "";
entry.statusNode.classList.add("hidden");
}
} else if (normalized && !reset) {
entry.button.textContent = normalized;
}
if (entry.descriptionNode) {
if (normalized && !reset) {
entry.descriptionNode.classList.add("hidden");
} else {
entry.descriptionNode.textContent = entry.defaultDescription;
entry.descriptionNode.classList.toggle(
"hidden",
!entry.defaultDescription,
);
}
}
}
startSlowTimer(providerId, entry) {
this.stopSlowTimer(providerId);
const delay = entry.provider.messages.slowDelay;
const timeout =
Number.isFinite(delay) && delay > 0 ? delay : SLOW_PROVIDER_DELAY_MS;
if (!this.window || typeof this.window.setTimeout !== "function") {
return;
}
const timerId = this.window.setTimeout(() => {
if (!entry.button.dataset.state || entry.button.dataset.state !== "loading") {
return;
}
const slowMessage =
entry.provider.messages.slow || "Waiting for the provider…";
this.setProviderStatus(providerId, {
message: slowMessage,
reset: false,
showDisconnect: false,
statusState: "waiting",
disableDisconnect: true,
});
}, timeout);
this.slowTimers.set(providerId, timerId);
}
stopSlowTimer(providerId) {
const timerId = this.slowTimers.get(providerId);
if (!timerId) {
return;
}
if (this.window && typeof this.window.clearTimeout === "function") {
this.window.clearTimeout(timerId);
}
this.slowTimers.delete(providerId);
}
async handleProviderSelection(providerId) {
const entry = this.getProviderEntry(providerId);
if (!entry) {
devLogger.warn(
`[LoginModalController] Ignoring selection for unknown provider: ${providerId}.`,
);
return;
}
if (entry.button.dataset.state === "loading") {
devLogger.log(
`[LoginModalController] Ignoring duplicate selection while ${providerId} is loading.`,
);
return;
}
if (!this.services.authService) {
userLogger.error(
"[LoginModalController] AuthService not available; cannot process login.",
);
return;
}
safeInvoke(this.callbacks.onProviderSelected, providerId);
let providerOptions = {};
if (entry.provider.id === "nsec") {
try {
const nsecOptions = await this.promptForNsecOptions();
if (!nsecOptions) {
devLogger.log(
"[LoginModalController] Direct key login cancelled by the user.",
);
if (entry.button instanceof HTMLButtonElement) {
entry.button.focus();
}
return;
}
providerOptions = nsecOptions;
} catch (promptError) {
devLogger.error(
"[LoginModalController] Failed to collect direct key credentials:",
promptError,
);
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);
devLogger.log(
`[LoginModalController] Starting login for provider ${providerId}.`,
);
const requestOptions = { providerId, ...providerOptions };
const extraOptions = await this.resolveNextRequestLoginOptions({
provider: entry.provider.source || entry.provider,
providerId,
});
if (extraOptions && typeof extraOptions === "object") {
Object.assign(requestOptions, extraOptions);
}
try {
const result = await this.services.authService.requestLogin(requestOptions);
devLogger.log(
`[LoginModalController] Login resolved for ${providerId} with pubkey:`,
result?.pubkey,
);
await safeInvokeAsync(this.callbacks.onLoginSuccess, {
provider: entry.provider.source || entry.provider,
result,
});
if (result && typeof result.pubkey === "string" && result.pubkey.trim()) {
this.helpers.closeModal();
}
} catch (error) {
devLogger.error(
`[LoginModalController] Login failed for ${providerId}:`,
error,
);
const message = this.helpers.describeLoginError(
error,
entry.provider.errorMessage,
);
if (
!message ||
(typeof message === "string" && !message.trim())
) {
devLogger.warn(
`[LoginModalController] describeLoginError returned empty message for ${providerId}.`,
);
}
await safeInvokeAsync(this.callbacks.onLoginError, {
provider: entry.provider.source || entry.provider,
error,
message,
});
} finally {
this.setLoadingState(providerId, false);
if (this.pendingNip46Cleanup) {
try {
this.pendingNip46Cleanup();
} catch (cleanupError) {
devLogger.warn(
"[LoginModalController] Failed to dispose remote signer form:",
cleanupError,
);
}
}
}
}
setNextRequestLoginOptions(options) {
if (typeof options === "function") {
this.nextRequestLoginOptionsResolver = options;
this.nextRequestLoginOptions = null;
return;
}
if (options && typeof options === "object") {
this.nextRequestLoginOptions = { ...options };
this.nextRequestLoginOptionsResolver = null;
return;
}
this.nextRequestLoginOptions = null;
this.nextRequestLoginOptionsResolver = null;
}
async resolveNextRequestLoginOptions(context = {}) {
if (typeof this.nextRequestLoginOptionsResolver === "function") {
const resolver = this.nextRequestLoginOptionsResolver;
this.nextRequestLoginOptionsResolver = null;
this.nextRequestLoginOptions = null;
try {
const resolved = await resolver({ controller: this, ...context });
if (resolved && typeof resolved === "object") {
return { ...resolved };
}
} catch (error) {
devLogger.warn(
"[LoginModalController] Next request options resolver threw:",
error,
);
}
return {};
}
if (this.nextRequestLoginOptions && typeof this.nextRequestLoginOptions === "object") {
const options = this.nextRequestLoginOptions;
this.nextRequestLoginOptions = null;
return { ...options };
}
return {};
}
destroy() {
if (this.providerContainer && this.boundClickHandler) {
this.providerContainer.removeEventListener("click", this.boundClickHandler);
}
for (const providerId of this.slowTimers.keys()) {
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.nextRequestLoginOptions = null;
this.nextRequestLoginOptionsResolver = null;
this.initialized = false;
}
}