diff --git a/app/components/place-details.gjs b/app/components/place-details.gjs index a665da0..df4d88b 100644 --- a/app/components/place-details.gjs +++ b/app/components/place-details.gjs @@ -515,7 +515,10 @@ export default class PlaceDetails extends Component {

{{/if}} - {{#if this.osmUrl}} + + + {{#if this.osmUrl}} +

@@ -524,9 +527,8 @@ export default class PlaceDetails extends Component {

- {{/if}} -
+ {{/if}} {{#if this.isPhotoUploadModalOpen}} diff --git a/app/components/user-menu.gjs b/app/components/user-menu.gjs index c1bac0f..84b9f85 100644 --- a/app/components/user-menu.gjs +++ b/app/components/user-menu.gjs @@ -3,13 +3,17 @@ import { action } from '@ember/object'; import { service } from '@ember/service'; import Icon from '#components/icon'; import { on } from '@ember/modifier'; +import { tracked } from '@glimmer/tracking'; +import { eq } from 'ember-truth-helpers'; +import Modal from './modal'; export default class UserMenuComponent extends Component { @service storage; @service osmAuth; - @service nostrAuth; + @tracked isNostrConnectModalOpen = false; + @action connectRS() { this.args.onClose(); @@ -33,9 +37,31 @@ export default class UserMenuComponent extends Component { } @action - async connectNostr() { + openNostrConnectModal() { + this.isNostrConnectModalOpen = true; + } + + @action + closeNostrConnectModal() { + this.isNostrConnectModalOpen = false; + } + + @action + async connectNostrExtension() { try { - await this.nostrAuth.login(); + await this.nostrAuth.login('extension'); + this.closeNostrConnectModal(); + } catch (e) { + console.error(e); + alert(e.message); + } + } + + @action + async connectNostrApp() { + try { + await this.nostrAuth.login('connect'); + this.closeNostrConnectModal(); } catch (e) { console.error(e); alert(e.message); @@ -47,6 +73,10 @@ export default class UserMenuComponent extends Component { this.nostrAuth.logout(); } + get hasExtension() { + return typeof window !== 'undefined' && typeof window.nostr !== 'undefined'; + } + } diff --git a/app/services/nostr-auth.js b/app/services/nostr-auth.js index a90e971..5c10564 100644 --- a/app/services/nostr-auth.js +++ b/app/services/nostr-auth.js @@ -1,64 +1,224 @@ -import Service from '@ember/service'; +import Service, { inject as service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; -import { ExtensionSigner } from 'applesauce-signers'; +import { + ExtensionSigner, + NostrConnectSigner, + PrivateKeySigner, +} from 'applesauce-signers'; const STORAGE_KEY = 'marco:nostr_pubkey'; +const STORAGE_KEY_TYPE = 'marco:nostr_signer_type'; // 'extension' | 'connect' +const STORAGE_KEY_CONNECT_LOCAL_KEY = 'marco:nostr_connect_local_key'; +const STORAGE_KEY_CONNECT_REMOTE_PUBKEY = 'marco:nostr_connect_remote_pubkey'; +const STORAGE_KEY_CONNECT_RELAY = 'marco:nostr_connect_relay'; export default class NostrAuthService extends Service { + @service nostrRelay; + @tracked pubkey = null; + @tracked signerType = null; // 'extension' or 'connect' + + // Track NostrConnect state for the UI + @tracked connectStatus = null; // null | 'waiting' | 'connected' + @tracked connectUri = null; // For displaying a QR code if needed + + _signerInstance = null; constructor() { super(...arguments); const saved = localStorage.getItem(STORAGE_KEY); + const type = localStorage.getItem(STORAGE_KEY_TYPE); if (saved) { this.pubkey = saved; + this.signerType = type || 'extension'; this._verifyPubkey(); } } async _verifyPubkey() { - if (typeof window.nostr === 'undefined') { - this.logout(); - return; - } - - try { - const signer = new ExtensionSigner(); - const extensionPubkey = await signer.getPublicKey(); - - if (extensionPubkey !== this.pubkey) { - this.pubkey = extensionPubkey; - localStorage.setItem(STORAGE_KEY, this.pubkey); + if (this.signerType === 'extension') { + if (typeof window.nostr === 'undefined') { + this.logout(); + return; + } + try { + const signer = new ExtensionSigner(); + const extensionPubkey = await signer.getPublicKey(); + if (extensionPubkey !== this.pubkey) { + this.pubkey = extensionPubkey; + localStorage.setItem(STORAGE_KEY, this.pubkey); + } + } catch (e) { + console.warn('Failed to verify extension nostr pubkey, logging out', e); + this.logout(); + } + } else if (this.signerType === 'connect') { + try { + await this._initConnectSigner(); + } catch (e) { + console.warn('Failed to verify connect nostr pubkey, logging out', e); + this.logout(); } - } catch (e) { - console.warn('Failed to verify nostr pubkey, logging out', e); - this.logout(); } } get isConnected() { - return !!this.pubkey; + return ( + !!this.pubkey && + (this.signerType === 'extension' + ? typeof window.nostr !== 'undefined' + : true) + ); } get signer() { - if (typeof window.nostr !== 'undefined') { + if (this._signerInstance) return this._signerInstance; + + if ( + this.signerType === 'extension' && + typeof window.nostr !== 'undefined' + ) { return new ExtensionSigner(); } + + if (this.signerType === 'connect') { + // Must be initialized async due to the connect handshakes + return null; + } + return null; } - async login() { - if (typeof window.nostr === 'undefined') { - throw new Error('No NIP-07 Nostr extension found (e.g., Alby, nos2x).'); + async login(type = 'extension') { + if (type === 'extension') { + if (typeof window.nostr === 'undefined') { + throw new Error('No NIP-07 Nostr extension found (e.g., Alby, nos2x).'); + } + + try { + this._signerInstance = new ExtensionSigner(); + this.pubkey = await this._signerInstance.getPublicKey(); + this.signerType = 'extension'; + localStorage.setItem(STORAGE_KEY, this.pubkey); + localStorage.setItem(STORAGE_KEY_TYPE, 'extension'); + return this.pubkey; + } catch (error) { + console.error('Failed to get public key from extension:', error); + throw error; + } + } else if (type === 'connect') { + this.connectStatus = 'waiting'; + + try { + // Generate or retrieve a local ephemeral keypair + let localKeyHex = localStorage.getItem(STORAGE_KEY_CONNECT_LOCAL_KEY); + let localSigner; + if (localKeyHex) { + localSigner = PrivateKeySigner.fromKey(localKeyHex); + } else { + localSigner = new PrivateKeySigner(); + // Store the raw Uint8Array as hex string + localKeyHex = Array.from(localSigner.key) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + localStorage.setItem(STORAGE_KEY_CONNECT_LOCAL_KEY, localKeyHex); + } + + // We use a specific relay for the connection handshake. + const relay = 'wss://relay.damus.io'; + localStorage.setItem(STORAGE_KEY_CONNECT_RELAY, relay); + + this._signerInstance = new NostrConnectSigner({ + pool: this.nostrRelay.pool, + relays: [relay], + signer: localSigner, + onAuth: async (url) => { + // NIP-46 auth callback. Normally the signer app does this natively via notification. + // But if it requires an explicit browser window: + if ( + confirm( + `Your signer app requests authentication via a web page. Open it now?\n\nURL: ${url}` + ) + ) { + window.open(url, '_blank'); + } + }, + }); + + // Set the uri for display (e.g., to redirect via intent) + this.connectUri = this._signerInstance.getNostrConnectURI({ + name: 'Marco', + url: window.location.origin, + description: 'A privacy-respecting maps application.', + icons: [], + }); + + // Trigger the deep link intent immediately for the user + window.location.href = this.connectUri; + + // Start listening to the relay + await this._signerInstance.open(); + + // Wait for the remote signer to reply with their pubkey + await this._signerInstance.waitForSigner(); + + // Once connected, get the actual user pubkey + this.pubkey = await this._signerInstance.getPublicKey(); + this.signerType = 'connect'; + this.connectStatus = 'connected'; + + // Save connection state + localStorage.setItem(STORAGE_KEY, this.pubkey); + localStorage.setItem(STORAGE_KEY_TYPE, 'connect'); + localStorage.setItem( + STORAGE_KEY_CONNECT_REMOTE_PUBKEY, + this._signerInstance.remote + ); + + return this.pubkey; + } catch (error) { + this.connectStatus = null; + console.error('Failed to connect via Nostr Connect:', error); + throw error; + } + } + } + + async _initConnectSigner() { + const localKeyHex = localStorage.getItem(STORAGE_KEY_CONNECT_LOCAL_KEY); + const remotePubkey = localStorage.getItem( + STORAGE_KEY_CONNECT_REMOTE_PUBKEY + ); + const relay = + localStorage.getItem(STORAGE_KEY_CONNECT_RELAY) || 'wss://relay.damus.io'; + + if (!localKeyHex || !remotePubkey) { + throw new Error('Missing Nostr Connect local state.'); } - try { - this.pubkey = await this.signer.getPublicKey(); - localStorage.setItem(STORAGE_KEY, this.pubkey); - return this.pubkey; - } catch (error) { - console.error('Failed to get public key from extension:', error); - throw error; + const localSigner = PrivateKeySigner.fromKey(localKeyHex); + + this._signerInstance = new NostrConnectSigner({ + pool: this.nostrRelay.pool, + relays: [relay], + signer: localSigner, + remote: remotePubkey, + onAuth: async (url) => { + if ( + confirm( + `Your signer app requests authentication via a web page. Open it now?\n\nURL: ${url}` + ) + ) { + window.open(url, '_blank'); + } + }, + }); + + await this._signerInstance.open(); + // Validate we can still get the pubkey from the remote signer + const pubkey = await this._signerInstance.getPublicKey(); + if (pubkey !== this.pubkey) { + throw new Error('Remote signer pubkey mismatch'); } } @@ -71,8 +231,23 @@ export default class NostrAuthService extends Service { return await this.signer.signEvent(event); } - logout() { + async logout() { this.pubkey = null; + this.signerType = null; + this.connectStatus = null; + this.connectUri = null; + if ( + this._signerInstance && + typeof this._signerInstance.close === 'function' + ) { + await this._signerInstance.close(); + } + this._signerInstance = null; + localStorage.removeItem(STORAGE_KEY); + localStorage.removeItem(STORAGE_KEY_TYPE); + localStorage.removeItem(STORAGE_KEY_CONNECT_LOCAL_KEY); + localStorage.removeItem(STORAGE_KEY_CONNECT_REMOTE_PUBKEY); + localStorage.removeItem(STORAGE_KEY_CONNECT_RELAY); } } diff --git a/app/styles/app.css b/app/styles/app.css index 8a27b66..4610463 100644 --- a/app/styles/app.css +++ b/app/styles/app.css @@ -1378,6 +1378,22 @@ button.create-place { } } +/* Nostr Connect */ +.nostr-connect-modal h2 { + margin-top: 0; +} + +.nostr-connect-options { + display: flex; + flex-direction: column; + gap: 1rem; + margin-top: 1.5rem; +} + +.nostr-connect-status { + margin-top: 1.5rem; +} + /* Modal */ .modal-overlay { position: fixed;