1009 lines
32 KiB
JavaScript
1009 lines
32 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;
|
||
|
||
// Initialize modal container and styles
|
||
this._initModal();
|
||
}
|
||
|
||
_initModal() {
|
||
// Create modal container
|
||
this.container = document.createElement('div');
|
||
this.container.id = 'nl-modal';
|
||
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: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
`;
|
||
|
||
// Create modal content
|
||
const modalContent = document.createElement('div');
|
||
modalContent.style.cssText = `
|
||
position: relative;
|
||
background: white;
|
||
width: 90%;
|
||
max-width: 400px;
|
||
margin: 50px auto;
|
||
border-radius: 12px;
|
||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||
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;
|
||
`;
|
||
|
||
const modalTitle = document.createElement('h2');
|
||
modalTitle.textContent = 'Nostr Login';
|
||
modalTitle.style.cssText = `
|
||
margin: 0;
|
||
font-size: 24px;
|
||
font-weight: 600;
|
||
color: #1f2937;
|
||
`;
|
||
|
||
const closeButton = document.createElement('button');
|
||
closeButton.innerHTML = '×';
|
||
closeButton.onclick = () => this.close();
|
||
closeButton.style.cssText = `
|
||
background: none;
|
||
border: none;
|
||
font-size: 28px;
|
||
color: #6b7280;
|
||
cursor: pointer;
|
||
padding: 0;
|
||
width: 32px;
|
||
height: 32px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 6px;
|
||
`;
|
||
closeButton.onmouseover = () => closeButton.style.background = '#f3f4f6';
|
||
closeButton.onmouseout = () => closeButton.style.background = 'none';
|
||
|
||
modalHeader.appendChild(modalTitle);
|
||
modalHeader.appendChild(closeButton);
|
||
|
||
// Body
|
||
this.modalBody = document.createElement('div');
|
||
this.modalBody.style.cssText = `
|
||
padding: 24px;
|
||
overflow-y: auto;
|
||
max-height: 500px;
|
||
`;
|
||
|
||
modalContent.appendChild(modalHeader);
|
||
modalContent.appendChild(this.modalBody);
|
||
this.container.appendChild(modalContent);
|
||
|
||
// Add to body
|
||
document.body.appendChild(this.container);
|
||
|
||
// Click outside to close
|
||
this.container.onclick = (e) => {
|
||
if (e.target === this.container) {
|
||
this.close();
|
||
}
|
||
};
|
||
|
||
// Update theme
|
||
this.updateTheme();
|
||
}
|
||
|
||
updateTheme() {
|
||
const isDark = this.options?.darkMode;
|
||
const modalContent = this.container.querySelector(':nth-child(1)');
|
||
const title = this.container.querySelector('h2');
|
||
|
||
if (isDark) {
|
||
modalContent.style.background = '#1f2937';
|
||
title.style.color = 'white';
|
||
} else {
|
||
modalContent.style.background = 'white';
|
||
title.style.color = '#1f2937';
|
||
}
|
||
}
|
||
|
||
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
|
||
if (this.options?.methods?.connect !== 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 && this.options?.otp) {
|
||
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: ${this.options?.darkMode ? '#374151' : 'white'};
|
||
border: 1px solid ${this.options?.darkMode ? '#4b5563' : '#d1d5db'};
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
`;
|
||
button.onmouseover = () => {
|
||
button.style.boxShadow = '0 4px 6px -1px rgba(0, 0, 0, 0.1)';
|
||
};
|
||
button.onmouseout = () => {
|
||
button.style.boxShadow = 'none';
|
||
};
|
||
|
||
const iconDiv = document.createElement('div');
|
||
iconDiv.textContent = option.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; text-align: left;';
|
||
|
||
const titleDiv = document.createElement('div');
|
||
titleDiv.textContent = option.title;
|
||
titleDiv.style.cssText = `
|
||
font-weight: 600;
|
||
margin-bottom: 4px;
|
||
color: ${this.options?.darkMode ? 'white' : '#1f2937'};
|
||
`;
|
||
|
||
const descDiv = document.createElement('div');
|
||
descDiv.textContent = option.description;
|
||
descDiv.style.cssText = `
|
||
font-size: 14px;
|
||
color: ${this.options?.darkMode ? '#9ca3af' : '#6b7280'};
|
||
`;
|
||
|
||
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();
|
||
|
||
if (availableExtensions.length === 0) {
|
||
console.log('Modal: No real extensions found');
|
||
this._showExtensionRequired();
|
||
} else if (availableExtensions.length === 1) {
|
||
console.log('Modal detected single extension:', availableExtensions[0].name);
|
||
this._tryExtensionLogin(availableExtensions[0].extension);
|
||
} else {
|
||
console.log('Modal detected multiple extensions:', availableExtensions.map(e => e.name));
|
||
this._showExtensionChoice(availableExtensions);
|
||
}
|
||
}
|
||
|
||
_detectAllExtensions() {
|
||
const extensions = [];
|
||
|
||
// Check navigator.nostr (NIP-07 standard location)
|
||
if (window.navigator?.nostr && typeof window.navigator.nostr.getPublicKey === 'function') {
|
||
extensions.push({
|
||
name: 'navigator.nostr',
|
||
displayName: 'Standard Extension (navigator.nostr)',
|
||
icon: '🌐',
|
||
extension: window.navigator.nostr
|
||
});
|
||
}
|
||
|
||
// Check webln.nostr (Alby WebLN)
|
||
if (window.webln?.nostr && typeof window.webln.nostr.getPublicKey === 'function') {
|
||
extensions.push({
|
||
name: 'webln.nostr',
|
||
displayName: 'Alby WebLN Extension',
|
||
icon: '⚡',
|
||
extension: window.webln.nostr
|
||
});
|
||
}
|
||
|
||
// Check alby.nostr (Alby direct)
|
||
if (window.alby?.nostr && typeof window.alby.nostr.getPublicKey === 'function') {
|
||
extensions.push({
|
||
name: 'alby.nostr',
|
||
displayName: 'Alby Extension (Direct)',
|
||
icon: '🐝',
|
||
extension: window.alby.nostr
|
||
});
|
||
}
|
||
|
||
// Check nos2x
|
||
if (window.nos2x && typeof window.nos2x.getPublicKey === 'function') {
|
||
extensions.push({
|
||
name: 'nos2x',
|
||
displayName: 'nos2x Extension',
|
||
icon: '🔌',
|
||
extension: window.nos2x
|
||
});
|
||
}
|
||
|
||
// Check window.nostr but make sure it's not our library
|
||
if (window.nostr && typeof window.nostr.getPublicKey === 'function') {
|
||
const isRealExtension = (
|
||
typeof window.nostr._hexToUint8Array !== 'function' && // Our library has this method
|
||
window.nostr.constructor.name !== 'Object' // Real extensions usually have proper constructors
|
||
);
|
||
|
||
if (isRealExtension) {
|
||
// Don't add if we already detected it via another path
|
||
const alreadyDetected = extensions.some(ext => ext.extension === window.nostr);
|
||
if (!alreadyDetected) {
|
||
extensions.push({
|
||
name: 'window.nostr',
|
||
displayName: 'Extension (window.nostr)',
|
||
icon: '🔑',
|
||
extension: window.nostr
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
return extensions;
|
||
}
|
||
|
||
_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;';
|
||
|
||
const description = document.createElement('p');
|
||
description.textContent = `Found ${extensions.length} Nostr extensions. Choose which one to use:`;
|
||
description.style.cssText = 'margin-bottom: 20px; color: #6b7280; font-size: 14px;';
|
||
|
||
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: ${this.options?.darkMode ? '#374151' : 'white'};
|
||
border: 1px solid ${this.options?.darkMode ? '#4b5563' : '#d1d5db'};
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
text-align: left;
|
||
`;
|
||
|
||
button.onmouseover = () => {
|
||
button.style.boxShadow = '0 4px 6px -1px rgba(0, 0, 0, 0.1)';
|
||
button.style.transform = 'translateY(-1px)';
|
||
};
|
||
button.onmouseout = () => {
|
||
button.style.boxShadow = 'none';
|
||
button.style.transform = 'none';
|
||
};
|
||
|
||
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: ${this.options?.darkMode ? 'white' : '#1f2937'};
|
||
`;
|
||
|
||
const pathDiv = document.createElement('div');
|
||
pathDiv.textContent = ext.name;
|
||
pathDiv.style.cssText = `
|
||
font-size: 12px;
|
||
color: ${this.options?.darkMode ? '#9ca3af' : '#6b7280'};
|
||
font-family: 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;
|
||
`;
|
||
|
||
// Pre-fill with our bunker config if available
|
||
if (window.NIP46_BUNKER_CONFIG) {
|
||
pubkeyInput.value = window.NIP46_BUNKER_CONFIG.remoteSigner.pubkey;
|
||
urlInput.value = window.NIP46_BUNKER_CONFIG.remoteSigner.url;
|
||
}
|
||
|
||
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.fromBunker() for bunker:// connections
|
||
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');
|
||
|
||
// Attempt initial ping to verify connection
|
||
console.log('Testing bunker connection with ping...');
|
||
await signer.ping();
|
||
console.log('NIP-46 ping successful - bunker is reachable');
|
||
|
||
// Try to connect (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: none;
|
||
border-radius: 8px;
|
||
font-size: 16px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
`;
|
||
|
||
if (type === 'primary') {
|
||
return baseStyle + `
|
||
background: #3b82f6;
|
||
color: white;
|
||
`;
|
||
} else {
|
||
return baseStyle + `
|
||
background: #6b7280;
|
||
color: white;
|
||
`;
|
||
}
|
||
}
|
||
|
||
// 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();
|
||
}); |