Files
nostr_login_lite/lite/ui/modal.js

1831 lines
64 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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, no fixed height
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);
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: 4px;
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;
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: '🔑'
});
}
// Seed Phrase option - only show if explicitly enabled
if (this.options?.methods?.seedphrase === true) {
options.push({
type: 'seedphrase',
title: 'Seed Phrase',
description: 'Import from mnemonic seed phrase',
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');
// Remove the icon entirely - no emojis or text-based icons
iconDiv.textContent = '';
iconDiv.style.cssText = `
font-size: 16px;
font-weight: bold;
margin-right: 16px;
width: 0px;
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 'seedphrase':
this._showSeedPhraseScreen();
break;
case 'connect':
this._showConnectScreen();
break;
case 'readonly':
this._handleReadonly();
break;
case 'otp':
this._showOtpScreen();
break;
}
}
_handleExtension() {
// SIMPLIFIED ARCHITECTURE: Check for single extension at window.nostr or preserved extension
let extension = null;
// Check if NostrLite instance has a preserved extension (real extension detected at init)
if (window.NOSTR_LOGIN_LITE?._instance?.preservedExtension) {
extension = window.NOSTR_LOGIN_LITE._instance.preservedExtension;
console.log('Modal: Using preserved extension:', extension.constructor?.name);
}
// Otherwise check current window.nostr
else if (window.nostr && this._isRealExtension(window.nostr)) {
extension = window.nostr;
console.log('Modal: Using current window.nostr extension:', extension.constructor?.name);
}
if (!extension) {
console.log('Modal: No extension detected yet, waiting for deferred detection...');
// DEFERRED EXTENSION CHECK: Extensions like nos2x might load after our library
let attempts = 0;
const maxAttempts = 10; // Try for 2 seconds
const checkForExtension = () => {
attempts++;
// Check again for preserved extension (might be set by deferred detection)
if (window.NOSTR_LOGIN_LITE?._instance?.preservedExtension) {
extension = window.NOSTR_LOGIN_LITE._instance.preservedExtension;
console.log('Modal: Found preserved extension after waiting:', extension.constructor?.name);
this._tryExtensionLogin(extension);
return;
}
// Check current window.nostr again
if (window.nostr && this._isRealExtension(window.nostr)) {
extension = window.nostr;
console.log('Modal: Found extension at window.nostr after waiting:', extension.constructor?.name);
this._tryExtensionLogin(extension);
return;
}
// Keep trying or give up
if (attempts < maxAttempts) {
setTimeout(checkForExtension, 200);
} else {
console.log('Modal: No browser extension found after waiting 2 seconds');
this._showExtensionRequired();
}
};
// Start checking after a brief delay
setTimeout(checkForExtension, 200);
return;
}
// Use the single detected extension directly - no choice UI
console.log('Modal: Single extension mode - using extension directly');
this._tryExtensionLogin(extension);
}
_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) {
// Check if window.nostr is our WindowNostr facade with a preserved extension
if (window.nostr.constructor?.name === 'WindowNostr' && window.nostr.existingNostr) {
console.log('Modal: Found WindowNostr facade, checking existingNostr for preserved extension');
const preservedExtension = window.nostr.existingNostr;
console.log('Modal: Preserved extension:', !!preservedExtension, preservedExtension?.constructor?.name);
if (preservedExtension && this._isRealExtension(preservedExtension) && !seenExtensions.has(preservedExtension)) {
extensions.push({
name: 'window.nostr.existingNostr',
displayName: 'Extension (preserved by WindowNostr)',
icon: '🔑',
extension: preservedExtension
});
seenExtensions.add(preservedExtension);
console.log(`Modal: ✓ Detected preserved extension: ${preservedExtension.constructor?.name}`);
}
}
// Check if window.nostr is directly a real extension (not our facade)
else if (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 {
console.log(`Modal: ✗ Filtered out window.nostr (${window.nostr.constructor?.name}) - not a real extension`);
}
}
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: 12px;';
// Helper function to create copy button
const createCopyButton = (text, label) => {
const copyBtn = document.createElement('button');
copyBtn.textContent = `Copy ${label}`;
copyBtn.style.cssText = `
margin-left: 8px;
padding: 4px 8px;
font-size: 10px;
background: var(--nl-secondary-color);
color: var(--nl-primary-color);
border: 1px solid var(--nl-primary-color);
border-radius: 4px;
cursor: pointer;
font-family: var(--nl-font-family, 'Courier New', monospace);
`;
copyBtn.onclick = async (e) => {
e.preventDefault();
try {
await navigator.clipboard.writeText(text);
const originalText = copyBtn.textContent;
copyBtn.textContent = '✓ Copied!';
copyBtn.style.color = '#059669';
setTimeout(() => {
copyBtn.textContent = originalText;
copyBtn.style.color = 'var(--nl-primary-color)';
}, 2000);
} catch (err) {
console.error('Failed to copy:', err);
copyBtn.textContent = '✗ Failed';
copyBtn.style.color = '#dc2626';
setTimeout(() => {
copyBtn.textContent = originalText;
copyBtn.style.color = 'var(--nl-primary-color)';
}, 2000);
}
};
return copyBtn;
};
// Convert pubkey to hex for verification
const pubkeyHex = typeof pubkey === 'string' ? pubkey : Array.from(pubkey).map(b => b.toString(16).padStart(2, '0')).join('');
// Decode nsec to get secret key as hex
let secretKeyHex = '';
try {
const decoded = window.NostrTools.nip19.decode(nsec);
secretKeyHex = Array.from(decoded.data).map(b => b.toString(16).padStart(2, '0')).join('');
} catch (err) {
console.error('Failed to decode nsec for hex display:', err);
}
// Secret Key Section
const nsecSection = document.createElement('div');
nsecSection.style.cssText = 'margin-bottom: 16px;';
const nsecLabel = document.createElement('div');
nsecLabel.innerHTML = '<strong>Your Secret Key (nsec):</strong>';
nsecLabel.style.cssText = 'margin-bottom: 4px; font-size: 12px; font-weight: 600;';
const nsecContainer = document.createElement('div');
nsecContainer.style.cssText = 'display: flex; align-items: flex-start; margin-bottom: 8px;';
const nsecCode = document.createElement('code');
nsecCode.textContent = nsec;
nsecCode.style.cssText = `
flex: 1;
word-break: break-all;
background: #f3f4f6;
padding: 6px;
border-radius: 4px;
font-size: 10px;
line-height: 1.3;
font-family: 'Courier New', monospace;
display: block;
`;
nsecContainer.appendChild(nsecCode);
nsecContainer.appendChild(createCopyButton(nsec, 'nsec'));
nsecSection.appendChild(nsecLabel);
nsecSection.appendChild(nsecContainer);
// Secret Key Hex Section
if (secretKeyHex) {
const secretHexLabel = document.createElement('div');
secretHexLabel.innerHTML = '<strong>Secret Key (hex):</strong>';
secretHexLabel.style.cssText = 'margin-bottom: 4px; font-size: 12px; font-weight: 600;';
const secretHexContainer = document.createElement('div');
secretHexContainer.style.cssText = 'display: flex; align-items: flex-start; margin-bottom: 8px;';
const secretHexCode = document.createElement('code');
secretHexCode.textContent = secretKeyHex;
secretHexCode.style.cssText = `
flex: 1;
word-break: break-all;
background: #f3f4f6;
padding: 6px;
border-radius: 4px;
font-size: 10px;
line-height: 1.3;
font-family: 'Courier New', monospace;
display: block;
`;
secretHexContainer.appendChild(secretHexCode);
secretHexContainer.appendChild(createCopyButton(secretKeyHex, 'hex'));
nsecSection.appendChild(secretHexLabel);
nsecSection.appendChild(secretHexContainer);
}
// Public Key Section
const npubSection = document.createElement('div');
npubSection.style.cssText = 'margin-bottom: 16px;';
const npub = window.NostrTools.nip19.npubEncode(pubkey);
const npubLabel = document.createElement('div');
npubLabel.innerHTML = '<strong>Your Public Key (npub):</strong>';
npubLabel.style.cssText = 'margin-bottom: 4px; font-size: 12px; font-weight: 600;';
const npubContainer = document.createElement('div');
npubContainer.style.cssText = 'display: flex; align-items: flex-start; margin-bottom: 8px;';
const npubCode = document.createElement('code');
npubCode.textContent = npub;
npubCode.style.cssText = `
flex: 1;
word-break: break-all;
background: #f3f4f6;
padding: 6px;
border-radius: 4px;
font-size: 10px;
line-height: 1.3;
font-family: 'Courier New', monospace;
display: block;
`;
npubContainer.appendChild(npubCode);
npubContainer.appendChild(createCopyButton(npub, 'npub'));
npubSection.appendChild(npubLabel);
npubSection.appendChild(npubContainer);
// Public Key Hex Section
const pubHexLabel = document.createElement('div');
pubHexLabel.innerHTML = '<strong>Public Key (hex):</strong>';
pubHexLabel.style.cssText = 'margin-bottom: 4px; font-size: 12px; font-weight: 600;';
const pubHexContainer = document.createElement('div');
pubHexContainer.style.cssText = 'display: flex; align-items: flex-start;';
const pubHexCode = document.createElement('code');
pubHexCode.textContent = pubkeyHex;
pubHexCode.style.cssText = `
flex: 1;
word-break: break-all;
background: #f3f4f6;
padding: 6px;
border-radius: 4px;
font-size: 10px;
line-height: 1.3;
font-family: 'Courier New', monospace;
display: block;
`;
pubHexContainer.appendChild(pubHexCode);
pubHexContainer.appendChild(createCopyButton(pubkeyHex, 'hex'));
npubSection.appendChild(pubHexLabel);
npubSection.appendChild(pubHexContainer);
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(nsecSection);
this.modalBody.appendChild(npubSection);
this.modalBody.appendChild(continueButton);
}
_setAuthMethod(method, options = {}) {
// SINGLE-EXTENSION ARCHITECTURE: Handle method switching
console.log('Modal: _setAuthMethod called with:', method, options);
// CRITICAL: Never install facade for extension methods - leave window.nostr as the extension
if (method === 'extension') {
console.log('Modal: Extension method - NOT installing facade, leaving window.nostr as extension');
// Emit auth method selection directly for extension
const event = new CustomEvent('nlMethodSelected', {
detail: { method, ...options }
});
window.dispatchEvent(event);
this.close();
return;
}
// For non-extension methods, we need to ensure WindowNostr facade is available
console.log('Modal: Non-extension method detected:', method);
// Check if we have a preserved extension but no WindowNostr facade installed
const hasPreservedExtension = !!window.NOSTR_LOGIN_LITE?._instance?.preservedExtension;
const hasWindowNostrFacade = window.nostr?.constructor?.name === 'WindowNostr';
console.log('Modal: Method switching check:');
console.log(' method:', method);
console.log(' hasPreservedExtension:', hasPreservedExtension);
console.log(' hasWindowNostrFacade:', hasWindowNostrFacade);
console.log(' current window.nostr constructor:', window.nostr?.constructor?.name);
// If we have a preserved extension but no facade, install facade for method switching
if (hasPreservedExtension && !hasWindowNostrFacade) {
console.log('Modal: Installing WindowNostr facade for method switching (non-extension authentication)');
// Get the NostrLite instance and install facade with preserved extension
const nostrLiteInstance = window.NOSTR_LOGIN_LITE?._instance;
if (nostrLiteInstance && typeof nostrLiteInstance._installFacade === 'function') {
const preservedExtension = nostrLiteInstance.preservedExtension;
console.log('Modal: Installing facade with preserved extension:', preservedExtension?.constructor?.name);
nostrLiteInstance._installFacade(preservedExtension);
console.log('Modal: WindowNostr facade installed for method switching');
} else {
console.error('Modal: Cannot access NostrLite instance or _installFacade method');
}
}
// If no extension at all, ensure facade is installed for local/NIP-46/readonly methods
else if (!hasPreservedExtension && !hasWindowNostrFacade) {
console.log('Modal: Installing WindowNostr facade for non-extension methods (no extension detected)');
const nostrLiteInstance = window.NOSTR_LOGIN_LITE?._instance;
if (nostrLiteInstance && typeof nostrLiteInstance._installFacade === 'function') {
nostrLiteInstance._installFacade();
console.log('Modal: WindowNostr facade installed for non-extension methods');
}
}
// 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.innerHTML = `
Please install a Nostr browser extension and refresh the page.<br><br>
<strong>Important:</strong> If you have multiple extensions installed, please disable all but one to avoid conflicts.
<br><br>
Popular extensions: Alby, nos2x, Flamingo
`;
message.style.cssText = 'margin-bottom: 20px; color: #6b7280; font-size: 14px; line-height: 1.4;';
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 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;
`;
// Add real-time bunker key validation
const formatHint = document.createElement('div');
formatHint.style.cssText = 'margin-bottom: 16px; font-size: 12px; color: #6b7280; min-height: 16px;';
const connectButton = document.createElement('button');
connectButton.textContent = 'Connect to Bunker';
connectButton.disabled = true;
connectButton.onclick = () => {
if (!connectButton.disabled) {
this._handleNip46Connect(pubkeyInput.value);
}
};
// Set initial disabled state
connectButton.style.cssText = `
display: block;
width: 100%;
padding: 12px;
border: var(--nl-border-width) solid var(--nl-muted-color);
border-radius: var(--nl-border-radius);
font-size: 16px;
font-weight: 500;
cursor: not-allowed;
transition: all 0.2s;
font-family: var(--nl-font-family, 'Courier New', monospace);
background: var(--nl-secondary-color);
color: var(--nl-muted-color);
margin-bottom: 12px;
`;
pubkeyInput.oninput = () => {
const value = pubkeyInput.value.trim();
if (!value) {
formatHint.textContent = '';
// Disable button
connectButton.disabled = true;
connectButton.style.borderColor = 'var(--nl-muted-color)';
connectButton.style.color = 'var(--nl-muted-color)';
connectButton.style.cursor = 'not-allowed';
return;
}
const isValid = this._validateBunkerKey(value);
if (isValid) {
formatHint.textContent = '✅ Valid bunker connection format detected';
formatHint.style.color = '#059669';
// Enable button
connectButton.disabled = false;
connectButton.style.borderColor = 'var(--nl-primary-color)';
connectButton.style.color = 'var(--nl-primary-color)';
connectButton.style.cursor = 'pointer';
} else {
formatHint.textContent = '❌ Invalid format - must be bunker://, npub, or 64-char hex';
formatHint.style.color = '#dc2626';
// Disable button
connectButton.disabled = true;
connectButton.style.borderColor = 'var(--nl-muted-color)';
connectButton.style.color = 'var(--nl-muted-color)';
connectButton.style.cursor = 'not-allowed';
}
};
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(formatHint);
this.modalBody.appendChild(description);
this.modalBody.appendChild(formGroup);
this.modalBody.appendChild(connectButton);
this.modalBody.appendChild(backButton);
}
_validateBunkerKey(bunkerKey) {
try {
const trimmed = bunkerKey.trim();
// Check for bunker:// format
if (trimmed.startsWith('bunker://')) {
// Should have format: bunker://pubkey or bunker://pubkey?param=value
const match = trimmed.match(/^bunker:\/\/([0-9a-fA-F]{64})(\?.*)?$/);
return !!match;
}
// Check for npub format
if (trimmed.startsWith('npub1') && trimmed.length === 63) {
try {
if (window.NostrTools?.nip19) {
const decoded = window.NostrTools.nip19.decode(trimmed);
return decoded.type === 'npub';
}
} catch {
return false;
}
}
// Check for hex format (64 characters, valid hex)
if (trimmed.length === 64 && /^[a-fA-F0-9]{64}$/.test(trimmed)) {
return true;
}
return false;
} catch (error) {
console.log('Bunker key validation failed:', error.message);
return false;
}
}
_handleNip46Connect(bunkerPubkey) {
if (!bunkerPubkey || !bunkerPubkey.length) {
this._showError('Bunker pubkey is required');
return;
}
this._showNip46Connecting(bunkerPubkey);
this._performNip46Connect(bunkerPubkey);
}
_showNip46Connecting(bunkerPubkey) {
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>
Connection: <code style="word-break: break-all;">${displayPubkey}</code><br>
<small style="color: #6b7280;">Connection string contains all necessary relay information.</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) {
try {
console.log('Starting NIP-46 connection to bunker:', bunkerPubkey);
// 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 factory method (not constructor - it's private)
console.log('Creating nip46 BunkerSigner...');
const signer = window.NostrTools.nip46.BunkerSigner.fromBunker(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');
}
_showSeedPhraseScreen() {
this.modalBody.innerHTML = '';
const description = document.createElement('p');
description.innerHTML = 'Enter your 12 or 24-word mnemonic seed phrase to derive Nostr accounts, or <span id="generate-new" style="text-decoration: underline; cursor: pointer; color: var(--nl-primary-color);">generate new</span>.';
description.style.cssText = 'margin-bottom: 12px; color: #6b7280; font-size: 14px;';
const textarea = document.createElement('textarea');
// Remove default placeholder text as requested
textarea.placeholder = '';
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 mnemonic validation
const formatHint = document.createElement('div');
formatHint.style.cssText = 'margin-bottom: 16px; font-size: 12px; color: #6b7280; min-height: 16px;';
const importButton = document.createElement('button');
importButton.textContent = 'Import Accounts';
importButton.disabled = true;
importButton.onclick = () => {
if (!importButton.disabled) {
this._importFromSeedPhrase(textarea.value);
}
};
// Set initial disabled state
importButton.style.cssText = `
display: block;
width: 100%;
padding: 12px;
border: var(--nl-border-width) solid var(--nl-muted-color);
border-radius: var(--nl-border-radius);
font-size: 16px;
font-weight: 500;
cursor: not-allowed;
transition: all 0.2s;
font-family: var(--nl-font-family, 'Courier New', monospace);
background: var(--nl-secondary-color);
color: var(--nl-muted-color);
`;
textarea.oninput = () => {
const value = textarea.value.trim();
if (!value) {
formatHint.textContent = '';
// Disable button
importButton.disabled = true;
importButton.style.borderColor = 'var(--nl-muted-color)';
importButton.style.color = 'var(--nl-muted-color)';
importButton.style.cursor = 'not-allowed';
return;
}
const isValid = this._validateMnemonic(value);
if (isValid) {
const wordCount = value.split(/\s+/).length;
formatHint.textContent = `✅ Valid ${wordCount}-word mnemonic detected`;
formatHint.style.color = '#059669';
// Enable button
importButton.disabled = false;
importButton.style.borderColor = 'var(--nl-primary-color)';
importButton.style.color = 'var(--nl-primary-color)';
importButton.style.cursor = 'pointer';
} else {
formatHint.textContent = '❌ Invalid mnemonic - must be 12 or 24 valid BIP-39 words';
formatHint.style.color = '#dc2626';
// Disable button
importButton.disabled = true;
importButton.style.borderColor = 'var(--nl-muted-color)';
importButton.style.color = 'var(--nl-muted-color)';
importButton.style.cursor = 'not-allowed';
}
};
const backButton = document.createElement('button');
backButton.textContent = 'Back';
backButton.onclick = () => this._renderLoginOptions();
backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 12px;';
this.modalBody.appendChild(description);
this.modalBody.appendChild(textarea);
this.modalBody.appendChild(formatHint);
this.modalBody.appendChild(importButton);
this.modalBody.appendChild(backButton);
// Add click handler for the "generate new" link
const generateLink = document.getElementById('generate-new');
if (generateLink) {
generateLink.addEventListener('mouseenter', () => {
generateLink.style.color = 'var(--nl-accent-color)';
});
generateLink.addEventListener('mouseleave', () => {
generateLink.style.color = 'var(--nl-primary-color)';
});
generateLink.addEventListener('click', () => {
this._generateNewSeedPhrase(textarea, formatHint);
});
}
}
_generateNewSeedPhrase(textarea, formatHint) {
try {
// Check if NIP-06 is available
if (!window.NostrTools?.nip06) {
throw new Error('NIP-06 not available in bundle');
}
// Generate a random 12-word mnemonic using NostrTools
const mnemonic = window.NostrTools.nip06.generateSeedWords();
// Set the generated mnemonic in the textarea
textarea.value = mnemonic;
// Trigger the oninput event to properly validate and enable the button
if (textarea.oninput) {
textarea.oninput();
}
console.log('Generated new seed phrase:', mnemonic.split(/\s+/).length, 'words');
} catch (error) {
console.error('Failed to generate seed phrase:', error);
formatHint.textContent = '❌ Failed to generate seed phrase - NIP-06 not available';
formatHint.style.color = '#dc2626';
}
}
_validateMnemonic(mnemonic) {
try {
// Check if NIP-06 is available
if (!window.NostrTools?.nip06) {
console.error('NIP-06 not available in bundle');
return false;
}
const words = mnemonic.trim().split(/\s+/);
// Must be 12 or 24 words
if (words.length !== 12 && words.length !== 24) {
return false;
}
// Try to validate using NostrTools nip06 - this will throw if invalid
window.NostrTools.nip06.privateKeyFromSeedWords(mnemonic, '', 0);
return true;
} catch (error) {
console.log('Mnemonic validation failed:', error.message);
return false;
}
}
_importFromSeedPhrase(mnemonic) {
try {
const trimmed = mnemonic.trim();
if (!trimmed) {
throw new Error('Please enter a mnemonic seed phrase');
}
// Validate the mnemonic
if (!this._validateMnemonic(trimmed)) {
throw new Error('Invalid mnemonic. Please enter a valid 12 or 24-word BIP-39 seed phrase');
}
// Generate accounts 0-5 using NIP-06
const accounts = [];
for (let i = 0; i < 6; i++) {
try {
const privateKey = window.NostrTools.nip06.privateKeyFromSeedWords(trimmed, '', i);
const publicKey = window.NostrTools.getPublicKey(privateKey);
const nsec = window.NostrTools.nip19.nsecEncode(privateKey);
const npub = window.NostrTools.nip19.npubEncode(publicKey);
accounts.push({
index: i,
privateKey,
publicKey,
nsec,
npub
});
} catch (error) {
console.error(`Failed to derive account ${i}:`, error);
}
}
if (accounts.length === 0) {
throw new Error('Failed to derive any accounts from seed phrase');
}
console.log(`Successfully derived ${accounts.length} accounts from seed phrase`);
this._showAccountSelection(accounts);
} catch (error) {
console.error('Seed phrase import failed:', error);
this._showError('Seed phrase import failed: ' + error.message);
}
}
_showAccountSelection(accounts) {
this.modalBody.innerHTML = '';
const description = document.createElement('p');
description.textContent = `Select which account to use (${accounts.length} accounts derived from seed phrase):`;
description.style.cssText = 'margin-bottom: 20px; color: #6b7280; font-size: 14px;';
this.modalBody.appendChild(description);
// Create table for account selection
const table = document.createElement('table');
table.style.cssText = `
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
font-family: var(--nl-font-family, 'Courier New', monospace);
font-size: 12px;
`;
// Table header
const thead = document.createElement('thead');
thead.innerHTML = `
<tr style="background: #f3f4f6;">
<th style="padding: 8px; text-align: center; border: 1px solid #d1d5db; font-weight: bold;">#</th>
<th style="padding: 8px; text-align: center; border: 1px solid #d1d5db; font-weight: bold;">Use</th>
</tr>
`;
table.appendChild(thead);
// Table body
const tbody = document.createElement('tbody');
accounts.forEach(account => {
const row = document.createElement('tr');
row.style.cssText = 'border: 1px solid #d1d5db;';
const indexCell = document.createElement('td');
indexCell.textContent = account.index;
indexCell.style.cssText = 'padding: 8px; text-align: center; border: 1px solid #d1d5db; font-weight: bold;';
const actionCell = document.createElement('td');
actionCell.style.cssText = 'padding: 8px; border: 1px solid #d1d5db;';
// Show truncated npub in the button
const truncatedNpub = `${account.npub.slice(0, 12)}...${account.npub.slice(-8)}`;
const selectButton = document.createElement('button');
selectButton.textContent = truncatedNpub;
selectButton.onclick = () => this._selectAccount(account);
selectButton.style.cssText = `
width: 100%;
padding: 8px 12px;
font-size: 11px;
background: var(--nl-secondary-color);
color: var(--nl-primary-color);
border: 1px solid var(--nl-primary-color);
border-radius: 4px;
cursor: pointer;
font-family: 'Courier New', monospace;
text-align: center;
`;
selectButton.onmouseover = () => {
selectButton.style.borderColor = 'var(--nl-accent-color)';
};
selectButton.onmouseout = () => {
selectButton.style.borderColor = 'var(--nl-primary-color)';
};
actionCell.appendChild(selectButton);
row.appendChild(indexCell);
row.appendChild(actionCell);
tbody.appendChild(row);
});
table.appendChild(tbody);
this.modalBody.appendChild(table);
// Back button
const backButton = document.createElement('button');
backButton.textContent = 'Back to Seed Phrase';
backButton.onclick = () => this._showSeedPhraseScreen();
backButton.style.cssText = this._getButtonStyle('secondary');
this.modalBody.appendChild(backButton);
}
_selectAccount(account) {
console.log('Selected account:', account.index, account.npub);
// Use the same auth method as local keys, but with seedphrase identifier
this._setAuthMethod('local', {
secret: account.nsec,
pubkey: account.publicKey,
source: 'seedphrase',
accountIndex: account.index
});
}
_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();
});