1172 lines
41 KiB
JavaScript
1172 lines
41 KiB
JavaScript
/**
|
||
* NOSTR_LOGIN_LITE Modal UI
|
||
* Minimal vanilla JS modal to replace Stencil/Tailwind component library
|
||
*/
|
||
|
||
class Modal {
|
||
constructor(options = {}) {
|
||
this.options = options;
|
||
this.container = null;
|
||
this.isVisible = false;
|
||
this.currentScreen = null;
|
||
this.isEmbedded = !!options.embedded;
|
||
this.embeddedContainer = options.embedded;
|
||
|
||
// Initialize modal container and styles
|
||
this._initModal();
|
||
}
|
||
|
||
_initModal() {
|
||
// Create modal container
|
||
this.container = document.createElement('div');
|
||
this.container.id = this.isEmbedded ? 'nl-modal-embedded' : 'nl-modal';
|
||
|
||
if (this.isEmbedded) {
|
||
// Embedded mode: inline positioning, no overlay
|
||
this.container.style.cssText = `
|
||
position: relative;
|
||
display: none;
|
||
font-family: var(--nl-font-family, 'Courier New', monospace);
|
||
width: 100%;
|
||
`;
|
||
} else {
|
||
// Modal mode: fixed overlay
|
||
this.container.style.cssText = `
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0, 0, 0, 0.75);
|
||
display: none;
|
||
z-index: 10000;
|
||
font-family: var(--nl-font-family, 'Courier New', monospace);
|
||
`;
|
||
}
|
||
|
||
// Create modal content
|
||
const modalContent = document.createElement('div');
|
||
if (this.isEmbedded) {
|
||
// Embedded content: no centering margin, full width
|
||
modalContent.style.cssText = `
|
||
position: relative;
|
||
background: var(--nl-secondary-color);
|
||
color: var(--nl-primary-color);
|
||
width: 100%;
|
||
border-radius: var(--nl-border-radius, 15px);
|
||
border: var(--nl-border-width) solid var(--nl-primary-color);
|
||
overflow: hidden;
|
||
`;
|
||
} else {
|
||
// Modal content: centered with margin
|
||
modalContent.style.cssText = `
|
||
position: relative;
|
||
background: var(--nl-secondary-color);
|
||
color: var(--nl-primary-color);
|
||
width: 90%;
|
||
max-width: 400px;
|
||
margin: 50px auto;
|
||
border-radius: var(--nl-border-radius, 15px);
|
||
border: var(--nl-border-width) solid var(--nl-primary-color);
|
||
max-height: 600px;
|
||
overflow: hidden;
|
||
`;
|
||
}
|
||
|
||
// Header
|
||
const modalHeader = document.createElement('div');
|
||
modalHeader.style.cssText = `
|
||
padding: 20px 24px 0 24px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
background: transparent;
|
||
border-bottom: none;
|
||
`;
|
||
|
||
const modalTitle = document.createElement('h2');
|
||
modalTitle.textContent = 'Nostr Login';
|
||
modalTitle.style.cssText = `
|
||
margin: 0;
|
||
font-size: 24px;
|
||
font-weight: 600;
|
||
color: var(--nl-primary-color);
|
||
font-family: var(--nl-font-family, 'Courier New', monospace);
|
||
`;
|
||
|
||
modalHeader.appendChild(modalTitle);
|
||
|
||
// Only add close button for non-embedded modals
|
||
// Embedded modals shouldn't have a close button because there's no way to reopen them
|
||
if (!this.isEmbedded) {
|
||
const closeButton = document.createElement('button');
|
||
closeButton.innerHTML = '×';
|
||
closeButton.onclick = () => this.close();
|
||
closeButton.style.cssText = `
|
||
background: var(--nl-secondary-color);
|
||
border: var(--nl-border-width) solid var(--nl-primary-color);
|
||
border-radius: var(--nl-border-radius);
|
||
font-size: 28px;
|
||
color: var(--nl-primary-color);
|
||
cursor: pointer;
|
||
padding: 0;
|
||
width: 32px;
|
||
height: 32px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-family: var(--nl-font-family, 'Courier New', monospace);
|
||
`;
|
||
closeButton.onmouseover = () => {
|
||
closeButton.style.borderColor = 'var(--nl-accent-color)';
|
||
closeButton.style.background = 'var(--nl-secondary-color)';
|
||
};
|
||
closeButton.onmouseout = () => {
|
||
closeButton.style.borderColor = 'var(--nl-primary-color)';
|
||
closeButton.style.background = 'var(--nl-secondary-color)';
|
||
};
|
||
|
||
modalHeader.appendChild(closeButton);
|
||
}
|
||
|
||
// Body
|
||
this.modalBody = document.createElement('div');
|
||
this.modalBody.style.cssText = `
|
||
padding: 24px;
|
||
overflow-y: auto;
|
||
max-height: 500px;
|
||
background: transparent;
|
||
font-family: var(--nl-font-family, 'Courier New', monospace);
|
||
`;
|
||
|
||
modalContent.appendChild(modalHeader);
|
||
modalContent.appendChild(this.modalBody);
|
||
this.container.appendChild(modalContent);
|
||
|
||
// Add to appropriate parent
|
||
if (this.isEmbedded && this.embeddedContainer) {
|
||
// Append to specified container for embedding
|
||
if (typeof this.embeddedContainer === 'string') {
|
||
const targetElement = document.querySelector(this.embeddedContainer);
|
||
if (targetElement) {
|
||
targetElement.appendChild(this.container);
|
||
} else {
|
||
console.error('NOSTR_LOGIN_LITE: Embedded container not found:', this.embeddedContainer);
|
||
document.body.appendChild(this.container);
|
||
}
|
||
} else if (this.embeddedContainer instanceof HTMLElement) {
|
||
this.embeddedContainer.appendChild(this.container);
|
||
} else {
|
||
console.error('NOSTR_LOGIN_LITE: Invalid embedded container');
|
||
document.body.appendChild(this.container);
|
||
}
|
||
} else {
|
||
// Add to body for modal mode
|
||
document.body.appendChild(this.container);
|
||
}
|
||
|
||
// Click outside to close (only for modal mode)
|
||
if (!this.isEmbedded) {
|
||
this.container.onclick = (e) => {
|
||
if (e.target === this.container) {
|
||
this.close();
|
||
}
|
||
};
|
||
}
|
||
|
||
// Update theme
|
||
this.updateTheme();
|
||
}
|
||
|
||
updateTheme() {
|
||
// The theme will automatically update through CSS custom properties
|
||
// No manual styling needed - the CSS variables handle everything
|
||
}
|
||
|
||
open(opts = {}) {
|
||
this.currentScreen = opts.startScreen;
|
||
this.isVisible = true;
|
||
this.container.style.display = 'block';
|
||
|
||
// Render login options
|
||
this._renderLoginOptions();
|
||
}
|
||
|
||
close() {
|
||
this.isVisible = false;
|
||
this.container.style.display = 'none';
|
||
this.modalBody.innerHTML = '';
|
||
}
|
||
|
||
_renderLoginOptions() {
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const options = [];
|
||
|
||
// Extension option
|
||
if (this.options?.methods?.extension !== false) {
|
||
options.push({
|
||
type: 'extension',
|
||
title: 'Browser Extension',
|
||
description: 'Use your browser extension',
|
||
icon: '🔌'
|
||
});
|
||
}
|
||
|
||
// Local key option
|
||
if (this.options?.methods?.local !== false) {
|
||
options.push({
|
||
type: 'local',
|
||
title: 'Local Key',
|
||
description: 'Create or import your own key',
|
||
icon: '🔑'
|
||
});
|
||
}
|
||
|
||
// Nostr Connect option (check both 'connect' and 'remote' for compatibility)
|
||
if (this.options?.methods?.connect !== false && this.options?.methods?.remote !== false) {
|
||
options.push({
|
||
type: 'connect',
|
||
title: 'Nostr Connect',
|
||
description: 'Connect with external signer',
|
||
icon: '🌐'
|
||
});
|
||
}
|
||
|
||
// Read-only option
|
||
if (this.options?.methods?.readonly !== false) {
|
||
options.push({
|
||
type: 'readonly',
|
||
title: 'Read Only',
|
||
description: 'Browse without signing',
|
||
icon: '👁️'
|
||
});
|
||
}
|
||
|
||
// OTP/DM option
|
||
if (this.options?.methods?.otp !== false) {
|
||
options.push({
|
||
type: 'otp',
|
||
title: 'DM/OTP',
|
||
description: 'Receive OTP via DM',
|
||
icon: '📱'
|
||
});
|
||
}
|
||
|
||
// Render each option
|
||
options.forEach(option => {
|
||
const button = document.createElement('button');
|
||
button.onclick = () => this._handleOptionClick(option.type);
|
||
button.style.cssText = `
|
||
display: flex;
|
||
align-items: center;
|
||
width: 100%;
|
||
padding: 16px;
|
||
margin-bottom: 12px;
|
||
background: var(--nl-secondary-color);
|
||
color: var(--nl-primary-color);
|
||
border: var(--nl-border-width) solid var(--nl-primary-color);
|
||
border-radius: var(--nl-border-radius);
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
font-family: var(--nl-font-family, 'Courier New', monospace);
|
||
`;
|
||
button.onmouseover = () => {
|
||
button.style.borderColor = 'var(--nl-accent-color)';
|
||
button.style.background = 'var(--nl-secondary-color)';
|
||
};
|
||
button.onmouseout = () => {
|
||
button.style.borderColor = 'var(--nl-primary-color)';
|
||
button.style.background = 'var(--nl-secondary-color)';
|
||
};
|
||
|
||
const iconDiv = document.createElement('div');
|
||
// Replace emoji icons with text-based ones
|
||
const iconMap = {
|
||
'🔌': '[EXT]',
|
||
'🔑': '[KEY]',
|
||
'🌐': '[NET]',
|
||
'👁️': '[VIEW]',
|
||
'📱': '[SMS]'
|
||
};
|
||
iconDiv.textContent = iconMap[option.icon] || option.icon;
|
||
iconDiv.style.cssText = `
|
||
font-size: 16px;
|
||
font-weight: bold;
|
||
margin-right: 16px;
|
||
width: 50px;
|
||
text-align: center;
|
||
color: var(--nl-primary-color);
|
||
font-family: var(--nl-font-family, 'Courier New', monospace);
|
||
`;
|
||
|
||
const contentDiv = document.createElement('div');
|
||
contentDiv.style.cssText = 'flex: 1; text-align: left;';
|
||
|
||
const titleDiv = document.createElement('div');
|
||
titleDiv.textContent = option.title;
|
||
titleDiv.style.cssText = `
|
||
font-weight: 600;
|
||
margin-bottom: 4px;
|
||
color: var(--nl-primary-color);
|
||
font-family: var(--nl-font-family, 'Courier New', monospace);
|
||
`;
|
||
|
||
const descDiv = document.createElement('div');
|
||
descDiv.textContent = option.description;
|
||
descDiv.style.cssText = `
|
||
font-size: 14px;
|
||
color: #666666;
|
||
font-family: var(--nl-font-family, 'Courier New', monospace);
|
||
`;
|
||
|
||
contentDiv.appendChild(titleDiv);
|
||
contentDiv.appendChild(descDiv);
|
||
|
||
button.appendChild(iconDiv);
|
||
button.appendChild(contentDiv);
|
||
this.modalBody.appendChild(button);
|
||
});
|
||
}
|
||
|
||
_handleOptionClick(type) {
|
||
console.log('Selected login type:', type);
|
||
|
||
// Handle different login types
|
||
switch (type) {
|
||
case 'extension':
|
||
this._handleExtension();
|
||
break;
|
||
case 'local':
|
||
this._showLocalKeyScreen();
|
||
break;
|
||
case 'connect':
|
||
this._showConnectScreen();
|
||
break;
|
||
case 'readonly':
|
||
this._handleReadonly();
|
||
break;
|
||
case 'otp':
|
||
this._showOtpScreen();
|
||
break;
|
||
}
|
||
}
|
||
|
||
_handleExtension() {
|
||
// Detect all available real extensions
|
||
const availableExtensions = this._detectAllExtensions();
|
||
|
||
console.log(`Modal: Found ${availableExtensions.length} extensions:`, availableExtensions.map(e => e.displayName));
|
||
|
||
if (availableExtensions.length === 0) {
|
||
console.log('Modal: No real extensions found');
|
||
this._showExtensionRequired();
|
||
} else if (availableExtensions.length === 1) {
|
||
// Single extension - use it directly without showing choice UI
|
||
console.log('Modal: Single extension detected, using it directly:', availableExtensions[0].displayName);
|
||
this._tryExtensionLogin(availableExtensions[0].extension);
|
||
} else {
|
||
// Multiple extensions - show choice UI
|
||
console.log('Modal: Multiple extensions detected, showing choice UI for', availableExtensions.length, 'extensions');
|
||
this._showExtensionChoice(availableExtensions);
|
||
}
|
||
}
|
||
|
||
_detectAllExtensions() {
|
||
const extensions = [];
|
||
const seenExtensions = new Set(); // Track extensions by object reference to avoid duplicates
|
||
|
||
// Extension locations to check (in priority order)
|
||
const locations = [
|
||
{ path: 'window.navigator?.nostr', name: 'navigator.nostr', displayName: 'Standard Extension (navigator.nostr)', icon: '🌐', getter: () => window.navigator?.nostr },
|
||
{ path: 'window.webln?.nostr', name: 'webln.nostr', displayName: 'Alby WebLN Extension', icon: '⚡', getter: () => window.webln?.nostr },
|
||
{ path: 'window.alby?.nostr', name: 'alby.nostr', displayName: 'Alby Extension (Direct)', icon: '🐝', getter: () => window.alby?.nostr },
|
||
{ path: 'window.nos2x', name: 'nos2x', displayName: 'nos2x Extension', icon: '🔌', getter: () => window.nos2x },
|
||
{ path: 'window.flamingo?.nostr', name: 'flamingo.nostr', displayName: 'Flamingo Extension', icon: '🦩', getter: () => window.flamingo?.nostr },
|
||
{ path: 'window.mutiny?.nostr', name: 'mutiny.nostr', displayName: 'Mutiny Extension', icon: '⚔️', getter: () => window.mutiny?.nostr },
|
||
{ path: 'window.nostrich?.nostr', name: 'nostrich.nostr', displayName: 'Nostrich Extension', icon: '🐦', getter: () => window.nostrich?.nostr },
|
||
{ path: 'window.getAlby?.nostr', name: 'getAlby.nostr', displayName: 'getAlby Extension', icon: '🔧', getter: () => window.getAlby?.nostr }
|
||
];
|
||
|
||
// Check each location
|
||
for (const location of locations) {
|
||
try {
|
||
const obj = location.getter();
|
||
|
||
console.log(`Modal: Checking ${location.name}:`, !!obj, obj?.constructor?.name);
|
||
|
||
if (obj && this._isRealExtension(obj) && !seenExtensions.has(obj)) {
|
||
extensions.push({
|
||
name: location.name,
|
||
displayName: location.displayName,
|
||
icon: location.icon,
|
||
extension: obj
|
||
});
|
||
seenExtensions.add(obj);
|
||
console.log(`Modal: ✓ Detected extension at ${location.name} (${obj.constructor?.name})`);
|
||
} else if (obj) {
|
||
console.log(`Modal: ✗ Filtered out ${location.name} (${obj.constructor?.name})`);
|
||
}
|
||
} catch (e) {
|
||
// Location doesn't exist or can't be accessed
|
||
console.log(`Modal: ${location.name} not accessible:`, e.message);
|
||
}
|
||
}
|
||
|
||
// Also check window.nostr but be extra careful to avoid our library
|
||
console.log('Modal: Checking window.nostr:', !!window.nostr, window.nostr?.constructor?.name);
|
||
if (window.nostr && this._isRealExtension(window.nostr) && !seenExtensions.has(window.nostr)) {
|
||
extensions.push({
|
||
name: 'window.nostr',
|
||
displayName: 'Extension (window.nostr)',
|
||
icon: '🔑',
|
||
extension: window.nostr
|
||
});
|
||
seenExtensions.add(window.nostr);
|
||
console.log(`Modal: ✓ Detected extension at window.nostr: ${window.nostr.constructor?.name}`);
|
||
} else if (window.nostr) {
|
||
console.log(`Modal: ✗ Filtered out window.nostr (${window.nostr.constructor?.name}) - likely our library`);
|
||
}
|
||
|
||
return extensions;
|
||
}
|
||
|
||
_isRealExtension(obj) {
|
||
console.log(`Modal: EXTENSIVE DEBUG - _isRealExtension called with:`, obj);
|
||
console.log(`Modal: Object type: ${typeof obj}`);
|
||
console.log(`Modal: Object truthy: ${!!obj}`);
|
||
|
||
if (!obj || typeof obj !== 'object') {
|
||
console.log(`Modal: REJECT - Not an object`);
|
||
return false;
|
||
}
|
||
|
||
console.log(`Modal: getPublicKey type: ${typeof obj.getPublicKey}`);
|
||
console.log(`Modal: signEvent type: ${typeof obj.signEvent}`);
|
||
|
||
// Must have required Nostr methods
|
||
if (typeof obj.getPublicKey !== 'function' || typeof obj.signEvent !== 'function') {
|
||
console.log(`Modal: REJECT - Missing required methods`);
|
||
return false;
|
||
}
|
||
|
||
// Exclude NostrTools library object
|
||
if (obj === window.NostrTools) {
|
||
console.log(`Modal: REJECT - Is NostrTools object`);
|
||
return false;
|
||
}
|
||
|
||
// Use the EXACT SAME logic as the comprehensive test (lines 804-809)
|
||
// This is the key fix - match the comprehensive test's successful detection logic
|
||
const constructorName = obj.constructor?.name;
|
||
const objectKeys = Object.keys(obj);
|
||
|
||
console.log(`Modal: Constructor name: "${constructorName}"`);
|
||
console.log(`Modal: Object keys: [${objectKeys.join(', ')}]`);
|
||
|
||
// COMPREHENSIVE TEST LOGIC - Accept anything with required methods that's not our specific library classes
|
||
const isRealExtension = (
|
||
typeof obj.getPublicKey === 'function' &&
|
||
typeof obj.signEvent === 'function' &&
|
||
constructorName !== 'WindowNostr' && // Our library class
|
||
constructorName !== 'NostrLite' // Our main class
|
||
);
|
||
|
||
console.log(`Modal: Using comprehensive test logic:`);
|
||
console.log(` Has getPublicKey: ${typeof obj.getPublicKey === 'function'}`);
|
||
console.log(` Has signEvent: ${typeof obj.signEvent === 'function'}`);
|
||
console.log(` Not WindowNostr: ${constructorName !== 'WindowNostr'}`);
|
||
console.log(` Not NostrLite: ${constructorName !== 'NostrLite'}`);
|
||
console.log(` Constructor: "${constructorName}"`);
|
||
|
||
// Additional debugging for comparison
|
||
const extensionPropChecks = {
|
||
_isEnabled: !!obj._isEnabled,
|
||
enabled: !!obj.enabled,
|
||
kind: !!obj.kind,
|
||
_eventEmitter: !!obj._eventEmitter,
|
||
_scope: !!obj._scope,
|
||
_requests: !!obj._requests,
|
||
_pubkey: !!obj._pubkey,
|
||
name: !!obj.name,
|
||
version: !!obj.version,
|
||
description: !!obj.description
|
||
};
|
||
|
||
console.log(`Modal: Extension property analysis:`, extensionPropChecks);
|
||
|
||
const hasExtensionProps = !!(
|
||
obj._isEnabled || obj.enabled || obj.kind ||
|
||
obj._eventEmitter || obj._scope || obj._requests || obj._pubkey ||
|
||
obj.name || obj.version || obj.description
|
||
);
|
||
|
||
const underscoreKeys = objectKeys.filter(key => key.startsWith('_'));
|
||
const hexToUint8Keys = objectKeys.filter(key => key.startsWith('_hex'));
|
||
console.log(`Modal: Underscore keys: [${underscoreKeys.join(', ')}]`);
|
||
console.log(`Modal: _hex* keys: [${hexToUint8Keys.join(', ')}]`);
|
||
|
||
console.log(`Modal: Additional analysis:`);
|
||
console.log(` hasExtensionProps: ${hasExtensionProps}`);
|
||
console.log(` hasLibraryMethod (_hexToUint8Array): ${objectKeys.includes('_hexToUint8Array')}`);
|
||
|
||
console.log(`Modal: COMPREHENSIVE TEST LOGIC RESULT: ${isRealExtension ? 'ACCEPT' : 'REJECT'}`);
|
||
console.log(`Modal: FINAL DECISION for ${constructorName}: ${isRealExtension ? 'ACCEPT' : 'REJECT'}`);
|
||
|
||
return isRealExtension;
|
||
}
|
||
|
||
_showExtensionChoice(extensions) {
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const title = document.createElement('h3');
|
||
title.textContent = 'Choose Browser Extension';
|
||
title.style.cssText = `
|
||
margin: 0 0 16px 0;
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: var(--nl-primary-color);
|
||
font-family: var(--nl-font-family, 'Courier New', monospace);
|
||
`;
|
||
|
||
const description = document.createElement('p');
|
||
description.textContent = `Found ${extensions.length} Nostr extensions. Choose which one to use:`;
|
||
description.style.cssText = `
|
||
margin-bottom: 20px;
|
||
color: #666666;
|
||
font-size: 14px;
|
||
font-family: var(--nl-font-family, 'Courier New', monospace);
|
||
`;
|
||
|
||
this.modalBody.appendChild(title);
|
||
this.modalBody.appendChild(description);
|
||
|
||
// Create button for each extension
|
||
extensions.forEach((ext, index) => {
|
||
const button = document.createElement('button');
|
||
button.onclick = () => this._tryExtensionLogin(ext.extension);
|
||
button.style.cssText = `
|
||
display: flex;
|
||
align-items: center;
|
||
width: 100%;
|
||
padding: 16px;
|
||
margin-bottom: 12px;
|
||
background: var(--nl-secondary-color);
|
||
color: var(--nl-primary-color);
|
||
border: var(--nl-border-width) solid var(--nl-primary-color);
|
||
border-radius: var(--nl-border-radius);
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
text-align: left;
|
||
font-family: var(--nl-font-family, 'Courier New', monospace);
|
||
`;
|
||
|
||
button.onmouseover = () => {
|
||
button.style.borderColor = 'var(--nl-accent-color)';
|
||
button.style.background = 'var(--nl-secondary-color)';
|
||
};
|
||
button.onmouseout = () => {
|
||
button.style.borderColor = 'var(--nl-primary-color)';
|
||
button.style.background = 'var(--nl-secondary-color)';
|
||
};
|
||
|
||
const iconDiv = document.createElement('div');
|
||
iconDiv.textContent = ext.icon;
|
||
iconDiv.style.cssText = `
|
||
font-size: 24px;
|
||
margin-right: 16px;
|
||
width: 24px;
|
||
text-align: center;
|
||
`;
|
||
|
||
const contentDiv = document.createElement('div');
|
||
contentDiv.style.cssText = 'flex: 1;';
|
||
|
||
const nameDiv = document.createElement('div');
|
||
nameDiv.textContent = ext.displayName;
|
||
nameDiv.style.cssText = `
|
||
font-weight: 600;
|
||
margin-bottom: 4px;
|
||
color: var(--nl-primary-color);
|
||
font-family: var(--nl-font-family, 'Courier New', monospace);
|
||
`;
|
||
|
||
const pathDiv = document.createElement('div');
|
||
pathDiv.textContent = ext.name;
|
||
pathDiv.style.cssText = `
|
||
font-size: 12px;
|
||
color: #666666;
|
||
font-family: var(--nl-font-family, 'Courier New', monospace);
|
||
`;
|
||
|
||
contentDiv.appendChild(nameDiv);
|
||
contentDiv.appendChild(pathDiv);
|
||
|
||
button.appendChild(iconDiv);
|
||
button.appendChild(contentDiv);
|
||
this.modalBody.appendChild(button);
|
||
});
|
||
|
||
// Add back button
|
||
const backButton = document.createElement('button');
|
||
backButton.textContent = 'Back to Login Options';
|
||
backButton.onclick = () => this._renderLoginOptions();
|
||
backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 20px;';
|
||
|
||
this.modalBody.appendChild(backButton);
|
||
}
|
||
|
||
async _tryExtensionLogin(extensionObj) {
|
||
try {
|
||
// Show loading state
|
||
this.modalBody.innerHTML = '<div style="text-align: center; padding: 20px;">🔄 Connecting to extension...</div>';
|
||
|
||
// Get pubkey from extension
|
||
const pubkey = await extensionObj.getPublicKey();
|
||
console.log('Extension provided pubkey:', pubkey);
|
||
|
||
// Set extension method with the extension object
|
||
this._setAuthMethod('extension', { pubkey, extension: extensionObj });
|
||
|
||
} catch (error) {
|
||
console.error('Extension login failed:', error);
|
||
this._showError(`Extension login failed: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
_showLocalKeyScreen() {
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const title = document.createElement('h3');
|
||
title.textContent = 'Local Key';
|
||
title.style.cssText = 'margin: 0 0 20px 0; font-size: 18px; font-weight: 600;';
|
||
|
||
const createButton = document.createElement('button');
|
||
createButton.textContent = 'Create New Key';
|
||
createButton.onclick = () => this._createLocalKey();
|
||
createButton.style.cssText = this._getButtonStyle();
|
||
|
||
const importButton = document.createElement('button');
|
||
importButton.textContent = 'Import Existing Key';
|
||
importButton.onclick = () => this._showImportKeyForm();
|
||
importButton.style.cssText = this._getButtonStyle() + 'margin-top: 12px;';
|
||
|
||
const backButton = document.createElement('button');
|
||
backButton.textContent = 'Back';
|
||
backButton.onclick = () => this._renderLoginOptions();
|
||
backButton.style.cssText = `
|
||
display: block;
|
||
margin-top: 20px;
|
||
padding: 12px;
|
||
background: #6b7280;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
`;
|
||
|
||
this.modalBody.appendChild(title);
|
||
this.modalBody.appendChild(createButton);
|
||
this.modalBody.appendChild(importButton);
|
||
this.modalBody.appendChild(backButton);
|
||
}
|
||
|
||
_createLocalKey() {
|
||
try {
|
||
const sk = window.NostrTools.generateSecretKey();
|
||
const pk = window.NostrTools.getPublicKey(sk);
|
||
const nsec = window.NostrTools.nip19.nsecEncode(sk);
|
||
const npub = window.NostrTools.nip19.npubEncode(pk);
|
||
|
||
this._showKeyDisplay(pk, nsec, 'created');
|
||
} catch (error) {
|
||
this._showError('Failed to create key: ' + error.message);
|
||
}
|
||
}
|
||
|
||
_showImportKeyForm() {
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const title = document.createElement('h3');
|
||
title.textContent = 'Import Local Key';
|
||
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600;';
|
||
|
||
const description = document.createElement('p');
|
||
description.textContent = 'Enter your secret key in either nsec or hex format:';
|
||
description.style.cssText = 'margin-bottom: 12px; color: #6b7280; font-size: 14px;';
|
||
|
||
const textarea = document.createElement('textarea');
|
||
textarea.placeholder = 'Enter your secret key:\n• nsec1... (bech32 format)\n• 64-character hex string';
|
||
textarea.style.cssText = `
|
||
width: 100%;
|
||
height: 100px;
|
||
padding: 12px;
|
||
border: 1px solid #d1d5db;
|
||
border-radius: 6px;
|
||
margin-bottom: 12px;
|
||
resize: none;
|
||
font-family: monospace;
|
||
font-size: 14px;
|
||
box-sizing: border-box;
|
||
`;
|
||
|
||
// Add real-time format detection
|
||
const formatHint = document.createElement('div');
|
||
formatHint.style.cssText = 'margin-bottom: 16px; font-size: 12px; color: #6b7280; min-height: 16px;';
|
||
|
||
textarea.oninput = () => {
|
||
const value = textarea.value.trim();
|
||
if (!value) {
|
||
formatHint.textContent = '';
|
||
return;
|
||
}
|
||
|
||
const format = this._detectKeyFormat(value);
|
||
if (format === 'nsec') {
|
||
formatHint.textContent = '✅ Valid nsec format detected';
|
||
formatHint.style.color = '#059669';
|
||
} else if (format === 'hex') {
|
||
formatHint.textContent = '✅ Valid hex format detected';
|
||
formatHint.style.color = '#059669';
|
||
} else {
|
||
formatHint.textContent = '❌ Invalid key format - must be nsec1... or 64-character hex';
|
||
formatHint.style.color = '#dc2626';
|
||
}
|
||
};
|
||
|
||
const importButton = document.createElement('button');
|
||
importButton.textContent = 'Import Key';
|
||
importButton.onclick = () => this._importLocalKey(textarea.value);
|
||
importButton.style.cssText = this._getButtonStyle();
|
||
|
||
const backButton = document.createElement('button');
|
||
backButton.textContent = 'Back';
|
||
backButton.onclick = () => this._showLocalKeyScreen();
|
||
backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 12px;';
|
||
|
||
this.modalBody.appendChild(title);
|
||
this.modalBody.appendChild(description);
|
||
this.modalBody.appendChild(textarea);
|
||
this.modalBody.appendChild(formatHint);
|
||
this.modalBody.appendChild(importButton);
|
||
this.modalBody.appendChild(backButton);
|
||
}
|
||
|
||
_detectKeyFormat(keyValue) {
|
||
const trimmed = keyValue.trim();
|
||
|
||
// Check for nsec format
|
||
if (trimmed.startsWith('nsec1') && trimmed.length === 63) {
|
||
try {
|
||
window.NostrTools.nip19.decode(trimmed);
|
||
return 'nsec';
|
||
} catch {
|
||
return 'invalid';
|
||
}
|
||
}
|
||
|
||
// Check for hex format (64 characters, valid hex)
|
||
if (trimmed.length === 64 && /^[a-fA-F0-9]{64}$/.test(trimmed)) {
|
||
return 'hex';
|
||
}
|
||
|
||
return 'invalid';
|
||
}
|
||
|
||
_importLocalKey(keyValue) {
|
||
try {
|
||
const trimmed = keyValue.trim();
|
||
if (!trimmed) {
|
||
throw new Error('Please enter a secret key');
|
||
}
|
||
|
||
const format = this._detectKeyFormat(trimmed);
|
||
let sk;
|
||
|
||
if (format === 'nsec') {
|
||
// Decode nsec format - this returns Uint8Array
|
||
const decoded = window.NostrTools.nip19.decode(trimmed);
|
||
if (decoded.type !== 'nsec') {
|
||
throw new Error('Invalid nsec format');
|
||
}
|
||
sk = decoded.data; // This is already Uint8Array
|
||
} else if (format === 'hex') {
|
||
// Convert hex string to Uint8Array
|
||
sk = this._hexToUint8Array(trimmed);
|
||
// Test that it's a valid secret key by trying to get public key
|
||
window.NostrTools.getPublicKey(sk);
|
||
} else {
|
||
throw new Error('Invalid key format. Please enter either nsec1... or 64-character hex string');
|
||
}
|
||
|
||
// Generate public key and encoded formats
|
||
const pk = window.NostrTools.getPublicKey(sk);
|
||
const nsec = window.NostrTools.nip19.nsecEncode(sk);
|
||
const npub = window.NostrTools.nip19.npubEncode(pk);
|
||
|
||
this._showKeyDisplay(pk, nsec, 'imported');
|
||
} catch (error) {
|
||
this._showError('Invalid key: ' + error.message);
|
||
}
|
||
}
|
||
|
||
_hexToUint8Array(hex) {
|
||
// Convert hex string to Uint8Array
|
||
if (hex.length % 2 !== 0) {
|
||
throw new Error('Invalid hex string length');
|
||
}
|
||
const bytes = new Uint8Array(hex.length / 2);
|
||
for (let i = 0; i < bytes.length; i++) {
|
||
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
|
||
}
|
||
return bytes;
|
||
}
|
||
|
||
_showKeyDisplay(pubkey, nsec, action) {
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const title = document.createElement('h3');
|
||
title.textContent = `Key ${action} successfully!`;
|
||
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600; color: #059669;';
|
||
|
||
const warningDiv = document.createElement('div');
|
||
warningDiv.textContent = '⚠️ Save your secret key securely!';
|
||
warningDiv.style.cssText = 'background: #fef3c7; color: #92400e; padding: 12px; border-radius: 6px; margin-bottom: 16px; font-size: 14px;';
|
||
|
||
const nsecDiv = document.createElement('div');
|
||
nsecDiv.innerHTML = `<strong>Your Secret Key:</strong><br><code style="word-break: break-all; background: #f3f4f6; padding: 8px; border-radius: 4px;">${nsec}</code>`;
|
||
nsecDiv.style.cssText = 'margin-bottom: 16px; font-size: 14px;';
|
||
|
||
const npubDiv = document.createElement('div');
|
||
npubDiv.innerHTML = `<strong>Your Public Key:</strong><br><code style="word-break: break-all; background: #f3f4f6; padding: 8px; border-radius: 4px;">${window.NostrTools.nip19.npubEncode(pubkey)}</code>`;
|
||
npubDiv.style.cssText = 'margin-bottom: 16px; font-size: 14px;';
|
||
|
||
const continueButton = document.createElement('button');
|
||
continueButton.textContent = 'Continue';
|
||
continueButton.onclick = () => this._setAuthMethod('local', { secret: nsec, pubkey });
|
||
continueButton.style.cssText = this._getButtonStyle();
|
||
|
||
this.modalBody.appendChild(title);
|
||
this.modalBody.appendChild(warningDiv);
|
||
this.modalBody.appendChild(nsecDiv);
|
||
this.modalBody.appendChild(npubDiv);
|
||
this.modalBody.appendChild(continueButton);
|
||
}
|
||
|
||
_setAuthMethod(method, options = {}) {
|
||
// Emit auth method selection
|
||
const event = new CustomEvent('nlMethodSelected', {
|
||
detail: { method, ...options }
|
||
});
|
||
window.dispatchEvent(event);
|
||
|
||
this.close();
|
||
}
|
||
|
||
_showError(message) {
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const errorDiv = document.createElement('div');
|
||
errorDiv.style.cssText = 'background: #fee2e2; color: #dc2626; padding: 16px; border-radius: 6px; margin-bottom: 16px;';
|
||
errorDiv.innerHTML = `<strong>Error:</strong> ${message}`;
|
||
|
||
const backButton = document.createElement('button');
|
||
backButton.textContent = 'Back';
|
||
backButton.onclick = () => this._renderLoginOptions();
|
||
backButton.style.cssText = this._getButtonStyle('secondary');
|
||
|
||
this.modalBody.appendChild(errorDiv);
|
||
this.modalBody.appendChild(backButton);
|
||
}
|
||
|
||
_showExtensionRequired() {
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const title = document.createElement('h3');
|
||
title.textContent = 'Browser Extension Required';
|
||
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600;';
|
||
|
||
const message = document.createElement('p');
|
||
message.textContent = 'Please install a Nostr browser extension like Alby or getflattr and refresh the page.';
|
||
message.style.cssText = 'margin-bottom: 20px; color: #6b7280;';
|
||
|
||
const backButton = document.createElement('button');
|
||
backButton.textContent = 'Back';
|
||
backButton.onclick = () => this._renderLoginOptions();
|
||
backButton.style.cssText = this._getButtonStyle('secondary');
|
||
|
||
this.modalBody.appendChild(title);
|
||
this.modalBody.appendChild(message);
|
||
this.modalBody.appendChild(backButton);
|
||
}
|
||
|
||
_showConnectScreen() {
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const title = document.createElement('h3');
|
||
title.textContent = 'Connect to NIP-46 Remote Signer';
|
||
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600;';
|
||
|
||
const description = document.createElement('p');
|
||
description.textContent = 'Connect to a remote signer (bunker) server to use its keys for signing.';
|
||
description.style.cssText = 'margin-bottom: 20px; color: #6b7280; font-size: 14px;';
|
||
|
||
const formGroup = document.createElement('div');
|
||
formGroup.style.cssText = 'margin-bottom: 20px;';
|
||
|
||
const label = document.createElement('label');
|
||
label.textContent = 'Bunker Public Key:';
|
||
label.style.cssText = 'display: block; margin-bottom: 8px; font-weight: 500;';
|
||
|
||
const pubkeyInput = document.createElement('input');
|
||
pubkeyInput.type = 'text';
|
||
pubkeyInput.placeholder = 'bunker://pubkey?relay=..., bunker:hex, hex, or npub...';
|
||
pubkeyInput.style.cssText = `
|
||
width: 100%;
|
||
padding: 12px;
|
||
border: 1px solid #d1d5db;
|
||
border-radius: 6px;
|
||
margin-bottom: 12px;
|
||
font-family: monospace;
|
||
box-sizing: border-box;
|
||
`;
|
||
|
||
const urlLabel = document.createElement('label');
|
||
urlLabel.textContent = 'Remote URL (optional):';
|
||
urlLabel.style.cssText = 'display: block; margin-bottom: 8px; font-weight: 500;';
|
||
|
||
const urlInput = document.createElement('input');
|
||
urlInput.type = 'url';
|
||
urlInput.placeholder = 'ws://localhost:8080 (default)';
|
||
urlInput.style.cssText = `
|
||
width: 100%;
|
||
padding: 12px;
|
||
border: 1px solid #d1d5db;
|
||
border-radius: 6px;
|
||
margin-bottom: 16px;
|
||
box-sizing: border-box;
|
||
`;
|
||
|
||
// Users will enter the bunker URL manually from their bunker setup
|
||
|
||
const connectButton = document.createElement('button');
|
||
connectButton.textContent = 'Connect to Bunker';
|
||
connectButton.onclick = () => this._handleNip46Connect(pubkeyInput.value, urlInput.value);
|
||
connectButton.style.cssText = this._getButtonStyle();
|
||
|
||
const backButton = document.createElement('button');
|
||
backButton.textContent = 'Back';
|
||
backButton.onclick = () => this._renderLoginOptions();
|
||
backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 12px;';
|
||
|
||
formGroup.appendChild(label);
|
||
formGroup.appendChild(pubkeyInput);
|
||
formGroup.appendChild(urlLabel);
|
||
formGroup.appendChild(urlInput);
|
||
|
||
this.modalBody.appendChild(title);
|
||
this.modalBody.appendChild(description);
|
||
this.modalBody.appendChild(formGroup);
|
||
this.modalBody.appendChild(connectButton);
|
||
this.modalBody.appendChild(backButton);
|
||
}
|
||
|
||
_handleNip46Connect(bunkerPubkey, bunkerUrl) {
|
||
if (!bunkerPubkey || !bunkerPubkey.length) {
|
||
this._showError('Bunker pubkey is required');
|
||
return;
|
||
}
|
||
|
||
this._showNip46Connecting(bunkerPubkey, bunkerUrl);
|
||
this._performNip46Connect(bunkerPubkey, bunkerUrl);
|
||
}
|
||
|
||
_showNip46Connecting(bunkerPubkey, bunkerUrl) {
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const title = document.createElement('h3');
|
||
title.textContent = 'Connecting to Remote Signer...';
|
||
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600; color: #059669;';
|
||
|
||
const description = document.createElement('p');
|
||
description.textContent = 'Establishing secure connection to your remote signer.';
|
||
description.style.cssText = 'margin-bottom: 20px; color: #6b7280;';
|
||
|
||
// Normalize bunker pubkey for display (= show original format if bunker: prefix)
|
||
const displayPubkey = bunkerPubkey.startsWith('bunker:') || bunkerPubkey.startsWith('npub') || bunkerPubkey.length === 64 ? bunkerPubkey : bunkerPubkey;
|
||
|
||
const bunkerInfo = document.createElement('div');
|
||
bunkerInfo.style.cssText = 'background: #f1f5f9; padding: 12px; border-radius: 6px; margin-bottom: 20px; font-size: 14px;';
|
||
bunkerInfo.innerHTML = `
|
||
<strong>Connecting to bunker:</strong><br>
|
||
Pubkey: <code style="word-break: break-all;">${displayPubkey}</code><br>
|
||
Relay: <code style="word-break: break-all;">${bunkerUrl || 'ws://localhost:8080'}</code><br>
|
||
<small style="color: #6b7280;">If this relay is offline, the bunker server may be unavailable.</small>
|
||
`;
|
||
|
||
const connectingDiv = document.createElement('div');
|
||
connectingDiv.style.cssText = 'text-align: center; color: #6b7280;';
|
||
connectingDiv.innerHTML = `
|
||
<div style="font-size: 24px; margin-bottom: 10px;">⏳</div>
|
||
<div>Please wait while we establish the connection...</div>
|
||
<div style="font-size: 12px; margin-top: 10px;">This may take a few seconds</div>
|
||
`;
|
||
|
||
this.modalBody.appendChild(title);
|
||
this.modalBody.appendChild(description);
|
||
this.modalBody.appendChild(bunkerInfo);
|
||
this.modalBody.appendChild(connectingDiv);
|
||
}
|
||
|
||
async _performNip46Connect(bunkerPubkey, bunkerUrl) {
|
||
try {
|
||
console.log('Starting NIP-46 connection to bunker:', bunkerPubkey, bunkerUrl);
|
||
|
||
// Check if nostr-tools NIP-46 is available
|
||
if (!window.NostrTools?.nip46) {
|
||
throw new Error('nostr-tools NIP-46 module not available');
|
||
}
|
||
|
||
// Use nostr-tools to parse bunker input - this handles all formats correctly
|
||
console.log('Parsing bunker input with nostr-tools...');
|
||
const bunkerPointer = await window.NostrTools.nip46.parseBunkerInput(bunkerPubkey);
|
||
|
||
if (!bunkerPointer) {
|
||
throw new Error('Unable to parse bunker connection string or resolve NIP-05 identifier');
|
||
}
|
||
|
||
console.log('Parsed bunker pointer:', bunkerPointer);
|
||
|
||
// Create local client keypair for this session
|
||
const localSecretKey = window.NostrTools.generateSecretKey();
|
||
console.log('Generated local client keypair for NIP-46 session');
|
||
|
||
// Use nostr-tools BunkerSigner constructor
|
||
console.log('Creating nip46 BunkerSigner...');
|
||
const signer = new window.NostrTools.nip46.BunkerSigner(localSecretKey, bunkerPointer, {
|
||
onauth: (url) => {
|
||
console.log('Received auth URL from bunker:', url);
|
||
// Open auth URL in popup or redirect
|
||
window.open(url, '_blank', 'width=600,height=800');
|
||
}
|
||
});
|
||
|
||
console.log('NIP-46 BunkerSigner created successfully');
|
||
|
||
// Skip ping test - NIP-46 works through relays, not direct connection
|
||
// Try to connect directly (this may trigger auth flow)
|
||
console.log('Attempting NIP-46 connect...');
|
||
await signer.connect();
|
||
console.log('NIP-46 connect successful');
|
||
|
||
// Get the user's public key from the bunker
|
||
console.log('Getting public key from bunker...');
|
||
const userPubkey = await signer.getPublicKey();
|
||
console.log('NIP-46 user public key:', userPubkey);
|
||
|
||
// Store the NIP-46 authentication info
|
||
const nip46Info = {
|
||
pubkey: userPubkey,
|
||
signer: {
|
||
method: 'nip46',
|
||
remotePubkey: bunkerPointer.pubkey,
|
||
bunkerSigner: signer,
|
||
secret: bunkerPointer.secret,
|
||
relays: bunkerPointer.relays
|
||
}
|
||
};
|
||
|
||
console.log('NOSTR_LOGIN_LITE NIP-46 connection established successfully!');
|
||
|
||
// Set as current auth method
|
||
this._setAuthMethod('nip46', nip46Info);
|
||
return;
|
||
|
||
} catch (error) {
|
||
console.error('NIP-46 connection failed:', error);
|
||
this._showNip46Error(error.message);
|
||
}
|
||
}
|
||
|
||
_showNip46Error(message) {
|
||
this.modalBody.innerHTML = '';
|
||
|
||
const title = document.createElement('h3');
|
||
title.textContent = 'Connection Failed';
|
||
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; font-weight: 600; color: #dc2626;';
|
||
|
||
const errorMsg = document.createElement('p');
|
||
errorMsg.textContent = `Unable to connect to remote signer: ${message}`;
|
||
errorMsg.style.cssText = 'margin-bottom: 20px; color: #6b7280;';
|
||
|
||
const retryButton = document.createElement('button');
|
||
retryButton.textContent = 'Try Again';
|
||
retryButton.onclick = () => this._showConnectScreen();
|
||
retryButton.style.cssText = this._getButtonStyle();
|
||
|
||
const backButton = document.createElement('button');
|
||
backButton.textContent = 'Back to Options';
|
||
backButton.onclick = () => this._renderLoginOptions();
|
||
backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 12px;';
|
||
|
||
this.modalBody.appendChild(title);
|
||
this.modalBody.appendChild(errorMsg);
|
||
this.modalBody.appendChild(retryButton);
|
||
this.modalBody.appendChild(backButton);
|
||
}
|
||
|
||
_handleReadonly() {
|
||
// Set read-only mode
|
||
this._setAuthMethod('readonly');
|
||
}
|
||
|
||
_showOtpScreen() {
|
||
// Placeholder for OTP functionality
|
||
this._showError('OTP/DM not yet implemented - coming soon!');
|
||
}
|
||
|
||
_getButtonStyle(type = 'primary') {
|
||
const baseStyle = `
|
||
display: block;
|
||
width: 100%;
|
||
padding: 12px;
|
||
border: var(--nl-border-width) solid var(--nl-primary-color);
|
||
border-radius: var(--nl-border-radius);
|
||
font-size: 16px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
font-family: var(--nl-font-family, 'Courier New', monospace);
|
||
`;
|
||
|
||
if (type === 'primary') {
|
||
return baseStyle + `
|
||
background: var(--nl-secondary-color);
|
||
color: var(--nl-primary-color);
|
||
`;
|
||
} else {
|
||
return baseStyle + `
|
||
background: #cccccc;
|
||
color: var(--nl-primary-color);
|
||
`;
|
||
}
|
||
}
|
||
|
||
// Public API
|
||
static init(options) {
|
||
if (Modal.instance) return Modal.instance;
|
||
Modal.instance = new Modal(options);
|
||
return Modal.instance;
|
||
}
|
||
|
||
static getInstance() {
|
||
return Modal.instance;
|
||
}
|
||
}
|
||
|
||
// Initialize global instance
|
||
let modalInstance = null;
|
||
|
||
window.addEventListener('load', () => {
|
||
modalInstance = new Modal();
|
||
}); |