mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
- replace hardcoded hex colors with rgb() equivalents in: - js/ui/loginModalController.js - js/ui/ambientBackground.js - js/ui/components/VideoModal.js - js/themeController.js - index.html - exclude config/instance-config.js and config/validate-config.js from check-hex.js linting - ensures CI lint jobs pass by adhering to token/rgb policy or exclusion lists Signed-off-by: Jules <[email protected]>
2525 lines
72 KiB
JavaScript
2525 lines
72 KiB
JavaScript
import { devLogger, userLogger } from "../utils/logger.js";
|
|
import { renderQrCode } from "../utils/qrcode.js";
|
|
|
|
const noop = () => {};
|
|
const SLOW_PROVIDER_DELAY_MS = 8_000;
|
|
const MODAL_CLOSE_POLL_INTERVAL_MS = 200;
|
|
|
|
function createError(code, message) {
|
|
const error = new Error(message);
|
|
if (code) {
|
|
error.code = code;
|
|
}
|
|
return error;
|
|
}
|
|
|
|
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,
|
|
openModal:
|
|
typeof helpers.openModal === "function" ? helpers.openModal : null,
|
|
prepareModal:
|
|
typeof helpers.prepareModal === "function" ? helpers.prepareModal : null,
|
|
setModalState:
|
|
typeof helpers.setModalState === "function" ? helpers.setModalState : null,
|
|
};
|
|
|
|
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.generateTemplate =
|
|
this.modalElement?.querySelector("template[data-login-generate-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.activeGenerateView = null;
|
|
this.pendingNip46Cleanup = null;
|
|
this.remoteSignerUnsubscribe = null;
|
|
this.lastRemoteSignerStatus = null;
|
|
this.nextRequestLoginOptions = null;
|
|
this.nextRequestLoginOptionsResolver = null;
|
|
this.pendingTask = null;
|
|
this.modalPrepared = false;
|
|
// Track generated keys so we only create one keypair per modal session.
|
|
this.generatedKeypair = null;
|
|
// Track modal close state to reset generated keys when the modal closes.
|
|
this.modalCloseObserver = null;
|
|
this.modalCloseIntervalId = null;
|
|
this.isSelectionInProgress = false;
|
|
this.nip46AutoStartTimer = 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();
|
|
// Start tracking modal close events to reset per-session key generation state.
|
|
this.initializeModalCloseTracking();
|
|
|
|
if (typeof this.lastRemoteSignerStatus !== "undefined") {
|
|
this.applyRemoteSignerStatus(this.lastRemoteSignerStatus);
|
|
}
|
|
|
|
this.providerContainer.addEventListener("click", this.boundClickHandler);
|
|
this.initialized = true;
|
|
}
|
|
|
|
initializeModalCloseTracking() {
|
|
const modal = this.modalElement;
|
|
if (!(modal instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
const handleClose = () => {
|
|
if (!this.isModalOpen()) {
|
|
// Reset generated keys once the modal is closed to allow a new keypair later.
|
|
this.resetGeneratedKeypair();
|
|
}
|
|
};
|
|
|
|
// Prefer a MutationObserver, fall back to polling if needed.
|
|
if (typeof MutationObserver === "function") {
|
|
if (this.modalCloseObserver) {
|
|
return;
|
|
}
|
|
try {
|
|
const observer = new MutationObserver(handleClose);
|
|
observer.observe(modal, {
|
|
attributes: true,
|
|
attributeFilter: ["data-open", "class"],
|
|
});
|
|
this.modalCloseObserver = observer;
|
|
return;
|
|
} catch (error) {
|
|
devLogger.warn(
|
|
"[LoginModalController] Failed to observe login modal close state:",
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (
|
|
this.modalCloseIntervalId ||
|
|
!this.window ||
|
|
typeof this.window.setInterval !== "function"
|
|
) {
|
|
return;
|
|
}
|
|
|
|
// Poll for modal close state as a fallback when observers are unavailable.
|
|
this.modalCloseIntervalId = this.window.setInterval(handleClose, 500);
|
|
}
|
|
|
|
resetGeneratedKeypair() {
|
|
// Clear cached keypair so the next modal session generates fresh keys.
|
|
this.generatedKeypair = null;
|
|
}
|
|
|
|
ensureModalPrepared() {
|
|
if (!(this.modalElement instanceof HTMLElement)) {
|
|
return null;
|
|
}
|
|
|
|
if (!this.modalPrepared && typeof this.helpers.prepareModal === "function") {
|
|
try {
|
|
const prepared = this.helpers.prepareModal(this.modalElement);
|
|
if (prepared instanceof HTMLElement) {
|
|
this.modalElement = prepared;
|
|
}
|
|
} catch (error) {
|
|
devLogger.warn(
|
|
"[LoginModalController] Failed to prepare login modal for accessibility:",
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
|
|
this.modalPrepared = true;
|
|
// Ensure close tracking starts after modal preparation updates the element.
|
|
this.initializeModalCloseTracking();
|
|
return this.modalElement instanceof HTMLElement ? this.modalElement : null;
|
|
}
|
|
|
|
isModalOpen() {
|
|
const modal = this.modalElement;
|
|
if (!(modal instanceof HTMLElement)) {
|
|
return false;
|
|
}
|
|
|
|
if (typeof modal.dataset?.open === "string") {
|
|
return modal.dataset.open === "true";
|
|
}
|
|
|
|
const attr = modal.getAttribute("data-open");
|
|
if (typeof attr === "string") {
|
|
return attr === "true";
|
|
}
|
|
|
|
return !modal.classList.contains("hidden");
|
|
}
|
|
|
|
openModal({ triggerElement } = {}) {
|
|
const modal = this.ensureModalPrepared();
|
|
if (!(modal instanceof HTMLElement)) {
|
|
throw this.createModalUnavailableError();
|
|
}
|
|
|
|
if (typeof this.helpers.openModal === "function") {
|
|
try {
|
|
const result = this.helpers.openModal({
|
|
modal,
|
|
triggerElement,
|
|
});
|
|
return result !== false;
|
|
} catch (error) {
|
|
devLogger.warn(
|
|
"[LoginModalController] Failed to open login modal via helper:",
|
|
error,
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
try {
|
|
modal.classList.remove("hidden");
|
|
modal.setAttribute("data-open", "true");
|
|
return true;
|
|
} catch (error) {
|
|
devLogger.warn(
|
|
"[LoginModalController] Failed to toggle login modal visibility:",
|
|
error,
|
|
);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
createModalUnavailableError() {
|
|
return createError("modal-unavailable", "Login modal unavailable.");
|
|
}
|
|
|
|
createLoginInProgressError() {
|
|
return createError(
|
|
"login-in-progress",
|
|
"Another login operation is already in progress.",
|
|
);
|
|
}
|
|
|
|
createModalOpenFailedError() {
|
|
return createError("modal-open-failed", "Unable to open login modal.");
|
|
}
|
|
|
|
createCancellationError() {
|
|
return createError("login-cancelled", "Login cancelled.");
|
|
}
|
|
|
|
startModalCloseWatch(task) {
|
|
const modal = this.modalElement;
|
|
if (!(modal instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
const handleClose = () => {
|
|
if (task.settled) {
|
|
return;
|
|
}
|
|
task.reject(this.createCancellationError());
|
|
};
|
|
|
|
if (typeof MutationObserver === "function") {
|
|
try {
|
|
const observer = new MutationObserver(() => {
|
|
if (!this.isModalOpen()) {
|
|
handleClose();
|
|
}
|
|
});
|
|
observer.observe(modal, {
|
|
attributes: true,
|
|
attributeFilter: ["data-open", "class"],
|
|
});
|
|
task.observer = observer;
|
|
return;
|
|
} catch (error) {
|
|
devLogger.warn(
|
|
"[LoginModalController] Failed to observe login modal close state:",
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (
|
|
this.window &&
|
|
typeof this.window.setInterval === "function" &&
|
|
typeof this.window.clearInterval === "function"
|
|
) {
|
|
const timerId = this.window.setInterval(() => {
|
|
if (!this.isModalOpen()) {
|
|
handleClose();
|
|
}
|
|
}, MODAL_CLOSE_POLL_INTERVAL_MS);
|
|
task.pollTimer = timerId;
|
|
}
|
|
}
|
|
|
|
stopModalCloseWatch(task) {
|
|
if (!task) {
|
|
return;
|
|
}
|
|
|
|
if (task.observer && typeof task.observer.disconnect === "function") {
|
|
try {
|
|
task.observer.disconnect();
|
|
} catch (error) {
|
|
devLogger.warn(
|
|
"[LoginModalController] Failed to disconnect login modal observer:",
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
|
|
task.observer = null;
|
|
|
|
if (
|
|
typeof task.pollTimer === "number" &&
|
|
this.window &&
|
|
typeof this.window.clearInterval === "function"
|
|
) {
|
|
try {
|
|
this.window.clearInterval(task.pollTimer);
|
|
} catch (error) {
|
|
devLogger.warn(
|
|
"[LoginModalController] Failed to clear login modal polling timer:",
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
|
|
task.pollTimer = null;
|
|
}
|
|
|
|
createPendingModalTask(type, { triggerElement } = {}) {
|
|
const modal = this.ensureModalPrepared();
|
|
if (!(modal instanceof HTMLElement)) {
|
|
throw this.createModalUnavailableError();
|
|
}
|
|
|
|
if (this.pendingTask) {
|
|
throw this.createLoginInProgressError();
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const task = {
|
|
type,
|
|
resolve: null,
|
|
reject: null,
|
|
settled: false,
|
|
observer: null,
|
|
pollTimer: null,
|
|
};
|
|
|
|
const finalize = (handler) => (value) => {
|
|
if (task.settled) {
|
|
return;
|
|
}
|
|
task.settled = true;
|
|
this.stopModalCloseWatch(task);
|
|
if (this.pendingTask === task) {
|
|
this.pendingTask = null;
|
|
}
|
|
if (
|
|
typeof this.helpers.setModalState === "function" &&
|
|
!this.isModalOpen()
|
|
) {
|
|
try {
|
|
this.helpers.setModalState("login", false);
|
|
} catch (error) {
|
|
devLogger.warn(
|
|
"[LoginModalController] Failed to synchronize login modal state:",
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
handler(value);
|
|
};
|
|
|
|
task.resolve = finalize((value) => resolve(value));
|
|
task.reject = finalize((errorValue) => reject(errorValue));
|
|
|
|
this.pendingTask = task;
|
|
|
|
this.startModalCloseWatch(task);
|
|
|
|
let opened = false;
|
|
try {
|
|
opened = this.openModal({ triggerElement });
|
|
} catch (error) {
|
|
devLogger.warn(
|
|
"[LoginModalController] Opening login modal threw:",
|
|
error,
|
|
);
|
|
}
|
|
|
|
if (!opened && !this.isModalOpen()) {
|
|
task.reject(this.createModalOpenFailedError());
|
|
return;
|
|
}
|
|
|
|
if (typeof this.helpers.setModalState === "function") {
|
|
try {
|
|
this.helpers.setModalState("login", true);
|
|
} catch (error) {
|
|
devLogger.warn(
|
|
"[LoginModalController] Failed to mark login modal as open in app state:",
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
resolvePendingTask(result, { type } = {}) {
|
|
const task = this.pendingTask;
|
|
if (!task || (type && task.type !== type) || typeof task.resolve !== "function") {
|
|
return false;
|
|
}
|
|
task.resolve(result);
|
|
return true;
|
|
}
|
|
|
|
rejectPendingTask(error, { type } = {}) {
|
|
const task = this.pendingTask;
|
|
if (!task || (type && task.type !== type) || typeof task.reject !== "function") {
|
|
return false;
|
|
}
|
|
task.reject(error);
|
|
return true;
|
|
}
|
|
|
|
requestAddProfileLogin({ triggerElement, requestOptions } = {}) {
|
|
const options = {
|
|
allowAccountSelection: true,
|
|
autoApply: false,
|
|
...(requestOptions && typeof requestOptions === "object"
|
|
? requestOptions
|
|
: {}),
|
|
};
|
|
|
|
let promise;
|
|
|
|
try {
|
|
promise = this.createPendingModalTask("add-profile", { triggerElement });
|
|
} catch (error) {
|
|
this.setNextRequestLoginOptions(null);
|
|
throw error;
|
|
}
|
|
|
|
this.setNextRequestLoginOptions(() => ({ ...options }));
|
|
|
|
return promise.finally(() => {
|
|
this.setNextRequestLoginOptions(null);
|
|
});
|
|
}
|
|
|
|
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 = false;
|
|
if (!shouldShow) {
|
|
passphraseInput.value = "";
|
|
}
|
|
}
|
|
if (passphraseConfirm instanceof HTMLInputElement) {
|
|
passphraseConfirm.disabled = !shouldShow;
|
|
passphraseConfirm.required = false;
|
|
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 = false;
|
|
if (!usingUnlock) {
|
|
unlockPassphrase.value = "";
|
|
}
|
|
}
|
|
if (secretField instanceof HTMLTextAreaElement) {
|
|
secretField.disabled = usingUnlock;
|
|
secretField.required = false;
|
|
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 debugUriContainer = form.querySelector("[data-nip46-debug-uri]");
|
|
const copyButton = form.querySelector("[data-nip46-copy-uri]");
|
|
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 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;
|
|
|
|
if (handshakeInput instanceof HTMLInputElement) {
|
|
handshakeInput.readOnly = true;
|
|
handshakeInput.value = "";
|
|
}
|
|
if (debugUriContainer instanceof HTMLElement) {
|
|
debugUriContainer.textContent = "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 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 (reuseButton instanceof HTMLButtonElement) {
|
|
reuseButton.disabled = disabled;
|
|
}
|
|
if (cancelButton instanceof HTMLButtonElement) {
|
|
cancelButton.disabled = disabled;
|
|
}
|
|
if (copyButton instanceof HTMLButtonElement) {
|
|
const hasUri =
|
|
handshakeInput instanceof HTMLInputElement &&
|
|
!!handshakeInput.value;
|
|
copyButton.disabled = disabled || !hasUri;
|
|
}
|
|
};
|
|
|
|
const updateHandshakeDisplay = (detail) => {
|
|
const uri =
|
|
detail && typeof detail === "object"
|
|
? detail.uri || detail.connectionString || ""
|
|
: "";
|
|
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 (qrContainer instanceof HTMLElement) {
|
|
qrContainer.innerHTML = "";
|
|
// Force high-contrast black-on-white for the QR code to ensure
|
|
// it remains readable and scannable regardless of the application theme.
|
|
// We resolve these from the unified token system to ensure consistency.
|
|
let qrLightColor = "rgb(255, 255, 255)";
|
|
let qrDarkColor = "rgb(0, 0, 0)";
|
|
|
|
if (this.window && typeof this.window.getComputedStyle === "function") {
|
|
try {
|
|
const style = this.window.getComputedStyle(this.document.documentElement);
|
|
const white = style.getPropertyValue("--color-white").trim();
|
|
const black = style.getPropertyValue("--color-black").trim();
|
|
if (white) qrLightColor = white;
|
|
if (black) qrDarkColor = black;
|
|
} catch (error) {
|
|
// Fallback to hardcoded values if style resolution fails
|
|
}
|
|
}
|
|
|
|
if (uri) {
|
|
try {
|
|
qrInstance = renderQrCode(qrContainer, uri, {
|
|
width: 256,
|
|
height: 256,
|
|
colorLight: qrLightColor,
|
|
colorDark: qrDarkColor,
|
|
});
|
|
} 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 copy button below.";
|
|
qrContainer.appendChild(fallback);
|
|
}
|
|
}
|
|
} else {
|
|
qrInstance = null;
|
|
}
|
|
}
|
|
setPendingState(false);
|
|
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);
|
|
};
|
|
|
|
let cancelHandler = null;
|
|
let reuseHandler = null;
|
|
let copyHandler = null;
|
|
let authOpenHandler = null;
|
|
|
|
const cleanup = () => {
|
|
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 (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();
|
|
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);
|
|
};
|
|
|
|
// Auto-submit immediately to generate the QR code
|
|
const autoStart = () => {
|
|
setError("");
|
|
resetStatus();
|
|
clearAuthChallenge();
|
|
|
|
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: true, // Always remember for now in simplified flow
|
|
metadata,
|
|
onHandshakePrepared: updateHandshakeDisplay,
|
|
onStatus: handleStatus,
|
|
onAuthUrl: handleAuthUrl,
|
|
},
|
|
{ keepMounted: true },
|
|
);
|
|
};
|
|
|
|
if (cancelButton instanceof HTMLButtonElement) {
|
|
cancelHandler = (event) => {
|
|
event.preventDefault();
|
|
if (settled) {
|
|
this.rejectPendingTask(this.createCancellationError(), {
|
|
type: "add-profile",
|
|
});
|
|
cleanup();
|
|
} else {
|
|
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 HTMLInputElement)) {
|
|
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;
|
|
}
|
|
}
|
|
if (copied) {
|
|
// 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. Use the manual fallback below.",
|
|
"warning",
|
|
);
|
|
}
|
|
};
|
|
copyButton.addEventListener("click", copyHandler);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// Kick off the handshake generation
|
|
this.nip46AutoStartTimer = setTimeout(autoStart, 0);
|
|
});
|
|
}
|
|
|
|
async promptForGenerateOptions() {
|
|
if (!this.modalBody || !this.generateTemplate) {
|
|
userLogger.warn("[LoginModalController] Account generation is unavailable.");
|
|
return null;
|
|
}
|
|
|
|
if (this.activeGenerateView) {
|
|
return null;
|
|
}
|
|
|
|
const fragment = this.generateTemplate.content
|
|
? this.generateTemplate.content.cloneNode(true)
|
|
: null;
|
|
if (!fragment) {
|
|
return null;
|
|
}
|
|
|
|
const view = fragment.querySelector("[data-generate-view]");
|
|
if (!(view instanceof HTMLElement)) {
|
|
return null;
|
|
}
|
|
|
|
const npubEl = view.querySelector("[data-generate-npub]");
|
|
const nsecEl = view.querySelector("[data-generate-nsec]");
|
|
const copyNpubBtn = view.querySelector("[data-generate-copy-npub]");
|
|
const copyNsecBtn = view.querySelector("[data-generate-copy-nsec]");
|
|
const revealNsecBtn = view.querySelector("[data-generate-reveal-nsec]");
|
|
const downloadBtn = view.querySelector("[data-generate-download]");
|
|
const confirmCheckbox = view.querySelector("[data-generate-confirm]");
|
|
const loginBtn = view.querySelector("[data-generate-login]");
|
|
const cancelBtn = view.querySelector("[data-generate-cancel]");
|
|
|
|
const tools = this.window?.NostrTools;
|
|
if (!tools || typeof tools.generateSecretKey !== "function" || typeof tools.getPublicKey !== "function") {
|
|
userLogger.error("Key generation unavailable: NostrTools missing.");
|
|
return null;
|
|
}
|
|
|
|
// Do not generate keys immediately. Wait for user action.
|
|
this.generatedKeypair = null;
|
|
let npub = "";
|
|
let nsec = "";
|
|
let hexSk = "";
|
|
|
|
// Reset fields
|
|
if (npubEl) {
|
|
npubEl.textContent = "";
|
|
}
|
|
if (nsecEl instanceof HTMLInputElement) {
|
|
nsecEl.value = "";
|
|
}
|
|
|
|
// Insert "Generate" button
|
|
const generateBtn = this.document.createElement("button");
|
|
generateBtn.type = "button";
|
|
generateBtn.className = "btn w-full mb-4";
|
|
generateBtn.textContent = "Generate New Keys";
|
|
// Insert before the first key field container, or at top of view?
|
|
// view structure: h3, p, div(space-y-3)
|
|
const contentContainer = view.querySelector(".space-y-3");
|
|
if (contentContainer) {
|
|
contentContainer.insertBefore(generateBtn, contentContainer.firstChild);
|
|
}
|
|
|
|
const keyContainer = contentContainer
|
|
? contentContainer.querySelector(".space-y-2")
|
|
: null;
|
|
if (keyContainer) {
|
|
keyContainer.classList.add("hidden");
|
|
}
|
|
// Also hide the nsec container
|
|
const nsecContainer = contentContainer
|
|
? contentContainer.querySelectorAll(".space-y-2")[1]
|
|
: null;
|
|
if (nsecContainer) {
|
|
nsecContainer.classList.add("hidden");
|
|
}
|
|
|
|
generateBtn.addEventListener("click", () => {
|
|
const sk = tools.generateSecretKey();
|
|
const pk = tools.getPublicKey(sk);
|
|
const npubGen = tools.nip19?.npubEncode ? tools.nip19.npubEncode(pk) : pk;
|
|
const nsecGen = tools.nip19?.nsecEncode ? tools.nip19.nsecEncode(sk) : "";
|
|
const hexSkGen = nsecGen
|
|
? nsecGen
|
|
: tools.nip19?.bytesToHex
|
|
? tools.nip19.bytesToHex(sk)
|
|
: "";
|
|
|
|
this.generatedKeypair = {
|
|
npub: npubGen,
|
|
nsec: nsecGen,
|
|
hexSk: hexSkGen,
|
|
pubkey: pk,
|
|
};
|
|
|
|
npub = npubGen;
|
|
nsec = nsecGen;
|
|
hexSk = hexSkGen;
|
|
|
|
if (npubEl) npubEl.textContent = npub;
|
|
if (nsecEl instanceof HTMLInputElement) nsecEl.value = nsec;
|
|
|
|
generateBtn.classList.add("hidden");
|
|
if (keyContainer) keyContainer.classList.remove("hidden");
|
|
if (nsecContainer) nsecContainer.classList.remove("hidden");
|
|
if (loginBtn) loginBtn.disabled = true; // Still requires checkbox confirmation
|
|
});
|
|
|
|
const elementsToHide = [];
|
|
for (const child of Array.from(this.modalBody.children)) {
|
|
if (child instanceof HTMLElement && child !== view) {
|
|
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(view);
|
|
this.activeGenerateView = view;
|
|
|
|
const cleanup = () => {
|
|
if (view.parentElement) {
|
|
view.parentElement.removeChild(view);
|
|
}
|
|
for (const entry of elementsToHide) {
|
|
if (!(entry?.element instanceof HTMLElement)) {
|
|
continue;
|
|
}
|
|
if (!entry.wasHidden) {
|
|
entry.element.classList.remove("hidden");
|
|
}
|
|
}
|
|
this.activeGenerateView = null;
|
|
};
|
|
|
|
return new Promise((resolve) => {
|
|
let settled = false;
|
|
const finish = (detail) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
cleanup();
|
|
resolve(detail);
|
|
};
|
|
|
|
// Interactions
|
|
if (cancelBtn) {
|
|
cancelBtn.addEventListener(
|
|
"click",
|
|
(e) => {
|
|
if (e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
finish(null);
|
|
},
|
|
{ once: true },
|
|
);
|
|
}
|
|
|
|
if (loginBtn) {
|
|
loginBtn.addEventListener("click", () => {
|
|
if (!this.generatedKeypair) {
|
|
return;
|
|
}
|
|
const { npub, nsec, hexSk } = this.generatedKeypair;
|
|
|
|
// Block generated accounts if whitelist-only access is enforced.
|
|
const accessControl = this.services?.authService?.accessControl;
|
|
if (accessControl && typeof accessControl.canAccess === "function") {
|
|
let canAccess = true;
|
|
try {
|
|
canAccess = accessControl.canAccess(npub);
|
|
} catch (error) {
|
|
devLogger.warn(
|
|
"[LoginModalController] accessControl.canAccess threw:",
|
|
error,
|
|
);
|
|
canAccess = Boolean(canAccess);
|
|
}
|
|
|
|
if (!canAccess && typeof accessControl.whitelistMode === "function") {
|
|
const isWhitelistMode = accessControl.whitelistMode();
|
|
if (isWhitelistMode) {
|
|
// Surface the access error via the login error callback/notification.
|
|
const accessError = new Error(
|
|
"Access restricted to admins and moderators users only.",
|
|
);
|
|
safeInvoke(this.callbacks.onLoginError, {
|
|
message: accessError.message,
|
|
error: accessError,
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Return credentials compatible with nsec provider
|
|
finish({ secret: nsec || hexSk, persist: false });
|
|
});
|
|
}
|
|
|
|
if (confirmCheckbox) {
|
|
confirmCheckbox.addEventListener("change", (e) => {
|
|
if (loginBtn) loginBtn.disabled = !e.target.checked;
|
|
});
|
|
}
|
|
|
|
const copyToClipboard = async (text, btn) => {
|
|
if (!text) return;
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
// Optional: visual feedback could be added here
|
|
} catch (err) {
|
|
devLogger.warn("Failed to copy:", err);
|
|
}
|
|
};
|
|
|
|
if (copyNpubBtn) {
|
|
copyNpubBtn.addEventListener("click", () => copyToClipboard(npub, copyNpubBtn));
|
|
}
|
|
|
|
if (copyNsecBtn) {
|
|
copyNsecBtn.addEventListener("click", () => copyToClipboard(nsec, copyNsecBtn));
|
|
}
|
|
|
|
if (revealNsecBtn && nsecEl instanceof HTMLInputElement) {
|
|
revealNsecBtn.addEventListener("click", () => {
|
|
const isPassword = nsecEl.type === "password";
|
|
nsecEl.type = isPassword ? "text" : "password";
|
|
// Icon toggle logic could go here if icons were separate elements
|
|
});
|
|
}
|
|
|
|
if (downloadBtn) {
|
|
downloadBtn.addEventListener("click", () => {
|
|
const content = `Nostr Keys Backup\n\nPublic Key (npub): ${npub}\nSecret Key (nsec): ${nsec}\n\nKEEP YOUR NSEC PRIVATE AND SAFE!`;
|
|
const blob = new Blob([content], { type: "text/plain" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = "nostr-keys.txt";
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
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) {
|
|
if (this.isSelectionInProgress) {
|
|
return;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
this.isSelectionInProgress = true;
|
|
try {
|
|
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;
|
|
}
|
|
} else if (entry.provider.id === "generate") {
|
|
try {
|
|
const generateOptions = await this.promptForGenerateOptions();
|
|
if (!generateOptions) {
|
|
devLogger.log(
|
|
"[LoginModalController] Account generation cancelled by the user.",
|
|
);
|
|
if (entry.button instanceof HTMLButtonElement) {
|
|
entry.button.focus();
|
|
}
|
|
return;
|
|
}
|
|
providerOptions = generateOptions;
|
|
// Switch provider context to 'nsec' since we are logging in with a key
|
|
providerId = "nsec";
|
|
const nsecEntry = this.getProviderEntry("nsec");
|
|
if (nsecEntry) {
|
|
entry = nsecEntry;
|
|
}
|
|
} catch (promptError) {
|
|
devLogger.error(
|
|
"[LoginModalController] Failed to process account generation:",
|
|
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,
|
|
);
|
|
|
|
const consumed = this.resolvePendingTask(result, { type: "add-profile" });
|
|
const shouldClose =
|
|
result &&
|
|
typeof result === "object" &&
|
|
typeof result.pubkey === "string" &&
|
|
result.pubkey.trim();
|
|
|
|
if (shouldClose) {
|
|
this.helpers.closeModal();
|
|
}
|
|
|
|
if (consumed) {
|
|
return;
|
|
}
|
|
|
|
await safeInvokeAsync(this.callbacks.onLoginSuccess, {
|
|
provider: entry.provider.source || entry.provider,
|
|
result,
|
|
});
|
|
} 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}.`,
|
|
);
|
|
}
|
|
|
|
const rejectionError =
|
|
error instanceof Error
|
|
? error
|
|
: new Error(message || "Failed to login. Please try again.");
|
|
|
|
if (message && message !== rejectionError.message) {
|
|
rejectionError.message = message;
|
|
}
|
|
|
|
if (
|
|
error &&
|
|
typeof error === "object" &&
|
|
typeof error.code === "string" &&
|
|
!rejectionError.code
|
|
) {
|
|
rejectionError.code = error.code;
|
|
}
|
|
|
|
const consumed = this.rejectPendingTask(rejectionError, {
|
|
type: "add-profile",
|
|
});
|
|
|
|
if (consumed) {
|
|
return;
|
|
}
|
|
|
|
await safeInvokeAsync(this.callbacks.onLoginError, {
|
|
provider: entry.provider.source || entry.provider,
|
|
error: rejectionError,
|
|
message,
|
|
});
|
|
}
|
|
} finally {
|
|
this.isSelectionInProgress = false;
|
|
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.modalCloseObserver) {
|
|
try {
|
|
this.modalCloseObserver.disconnect();
|
|
} catch (error) {
|
|
// no-op
|
|
}
|
|
}
|
|
this.modalCloseObserver = null;
|
|
|
|
if (this.modalCloseIntervalId) {
|
|
if (this.window && typeof this.window.clearInterval === "function") {
|
|
this.window.clearInterval(this.modalCloseIntervalId);
|
|
}
|
|
}
|
|
this.modalCloseIntervalId = null;
|
|
|
|
if (this.nip46AutoStartTimer) {
|
|
if (this.window && typeof this.window.clearTimeout === "function") {
|
|
this.window.clearTimeout(this.nip46AutoStartTimer);
|
|
}
|
|
this.nip46AutoStartTimer = null;
|
|
}
|
|
|
|
if (this.pendingTask && typeof this.pendingTask.reject === "function") {
|
|
try {
|
|
this.pendingTask.reject(this.createCancellationError());
|
|
} catch (error) {
|
|
devLogger.warn(
|
|
"[LoginModalController] Failed to cancel pending login task during destroy:",
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
|
|
this.pendingTask = null;
|
|
|
|
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;
|
|
}
|
|
}
|