Connect Nostr via mobile app
This commit is contained in:
@@ -515,7 +515,10 @@ export default class PlaceDetails extends Component {
|
|||||||
</p>
|
</p>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
{{#if this.osmUrl}}
|
</div>
|
||||||
|
|
||||||
|
{{#if this.osmUrl}}
|
||||||
|
<div class="meta-info">
|
||||||
<p class="content-with-icon">
|
<p class="content-with-icon">
|
||||||
<Icon @name="camera" />
|
<Icon @name="camera" />
|
||||||
<span>
|
<span>
|
||||||
@@ -524,9 +527,8 @@ export default class PlaceDetails extends Component {
|
|||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
{{/if}}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{#if this.isPhotoUploadModalOpen}}
|
{{#if this.isPhotoUploadModalOpen}}
|
||||||
|
|||||||
@@ -3,13 +3,17 @@ import { action } from '@ember/object';
|
|||||||
import { service } from '@ember/service';
|
import { service } from '@ember/service';
|
||||||
import Icon from '#components/icon';
|
import Icon from '#components/icon';
|
||||||
import { on } from '@ember/modifier';
|
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 {
|
export default class UserMenuComponent extends Component {
|
||||||
@service storage;
|
@service storage;
|
||||||
@service osmAuth;
|
@service osmAuth;
|
||||||
|
|
||||||
@service nostrAuth;
|
@service nostrAuth;
|
||||||
|
|
||||||
|
@tracked isNostrConnectModalOpen = false;
|
||||||
|
|
||||||
@action
|
@action
|
||||||
connectRS() {
|
connectRS() {
|
||||||
this.args.onClose();
|
this.args.onClose();
|
||||||
@@ -33,9 +37,31 @@ export default class UserMenuComponent extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
async connectNostr() {
|
openNostrConnectModal() {
|
||||||
|
this.isNostrConnectModalOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
closeNostrConnectModal() {
|
||||||
|
this.isNostrConnectModalOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async connectNostrExtension() {
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
alert(e.message);
|
alert(e.message);
|
||||||
@@ -47,6 +73,10 @@ export default class UserMenuComponent extends Component {
|
|||||||
this.nostrAuth.logout();
|
this.nostrAuth.logout();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get hasExtension() {
|
||||||
|
return typeof window !== 'undefined' && typeof window.nostr !== 'undefined';
|
||||||
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="user-menu-popover">
|
<div class="user-menu-popover">
|
||||||
<ul class="account-list">
|
<ul class="account-list">
|
||||||
@@ -124,7 +154,7 @@ export default class UserMenuComponent extends Component {
|
|||||||
<button
|
<button
|
||||||
class="btn-text text-primary"
|
class="btn-text text-primary"
|
||||||
type="button"
|
type="button"
|
||||||
{{on "click" this.connectNostr}}
|
{{on "click" this.openNostrConnectModal}}
|
||||||
>Connect</button>
|
>Connect</button>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
@@ -140,5 +170,49 @@ export default class UserMenuComponent extends Component {
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{#if this.isNostrConnectModalOpen}}
|
||||||
|
<Modal @onClose={{this.closeNostrConnectModal}}>
|
||||||
|
<div class="nostr-connect-modal">
|
||||||
|
<h2>Connect with Nostr</h2>
|
||||||
|
|
||||||
|
<div class="nostr-connect-options">
|
||||||
|
{{#if this.hasExtension}}
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
type="button"
|
||||||
|
{{on "click" this.connectNostrExtension}}
|
||||||
|
>
|
||||||
|
Browser Extension (nos2x, Alby)
|
||||||
|
</button>
|
||||||
|
{{else}}
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary"
|
||||||
|
type="button"
|
||||||
|
disabled
|
||||||
|
title="No Nostr extension found in your browser."
|
||||||
|
>
|
||||||
|
Browser Extension (Not Found)
|
||||||
|
</button>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
type="button"
|
||||||
|
{{on "click" this.connectNostrApp}}
|
||||||
|
>
|
||||||
|
Mobile Signer App (Amber, etc.)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{#if (eq this.nostrAuth.connectStatus "waiting")}}
|
||||||
|
<div class="alert alert-info nostr-connect-status">
|
||||||
|
<p>Waiting for you to approve the connection in your mobile signer
|
||||||
|
app...</p>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
{{/if}}
|
||||||
</template>
|
</template>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,64 +1,224 @@
|
|||||||
import Service from '@ember/service';
|
import Service, { inject as service } from '@ember/service';
|
||||||
import { tracked } from '@glimmer/tracking';
|
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 = '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 {
|
export default class NostrAuthService extends Service {
|
||||||
|
@service nostrRelay;
|
||||||
|
|
||||||
@tracked pubkey = null;
|
@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() {
|
constructor() {
|
||||||
super(...arguments);
|
super(...arguments);
|
||||||
const saved = localStorage.getItem(STORAGE_KEY);
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
const type = localStorage.getItem(STORAGE_KEY_TYPE);
|
||||||
if (saved) {
|
if (saved) {
|
||||||
this.pubkey = saved;
|
this.pubkey = saved;
|
||||||
|
this.signerType = type || 'extension';
|
||||||
this._verifyPubkey();
|
this._verifyPubkey();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async _verifyPubkey() {
|
async _verifyPubkey() {
|
||||||
if (typeof window.nostr === 'undefined') {
|
if (this.signerType === 'extension') {
|
||||||
this.logout();
|
if (typeof window.nostr === 'undefined') {
|
||||||
return;
|
this.logout();
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const signer = new ExtensionSigner();
|
const signer = new ExtensionSigner();
|
||||||
const extensionPubkey = await signer.getPublicKey();
|
const extensionPubkey = await signer.getPublicKey();
|
||||||
|
if (extensionPubkey !== this.pubkey) {
|
||||||
if (extensionPubkey !== this.pubkey) {
|
this.pubkey = extensionPubkey;
|
||||||
this.pubkey = extensionPubkey;
|
localStorage.setItem(STORAGE_KEY, this.pubkey);
|
||||||
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() {
|
get isConnected() {
|
||||||
return !!this.pubkey;
|
return (
|
||||||
|
!!this.pubkey &&
|
||||||
|
(this.signerType === 'extension'
|
||||||
|
? typeof window.nostr !== 'undefined'
|
||||||
|
: true)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
get signer() {
|
get signer() {
|
||||||
if (typeof window.nostr !== 'undefined') {
|
if (this._signerInstance) return this._signerInstance;
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.signerType === 'extension' &&
|
||||||
|
typeof window.nostr !== 'undefined'
|
||||||
|
) {
|
||||||
return new ExtensionSigner();
|
return new ExtensionSigner();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.signerType === 'connect') {
|
||||||
|
// Must be initialized async due to the connect handshakes
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async login() {
|
async login(type = 'extension') {
|
||||||
if (typeof window.nostr === 'undefined') {
|
if (type === 'extension') {
|
||||||
throw new Error('No NIP-07 Nostr extension found (e.g., Alby, nos2x).');
|
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 {
|
const localSigner = PrivateKeySigner.fromKey(localKeyHex);
|
||||||
this.pubkey = await this.signer.getPublicKey();
|
|
||||||
localStorage.setItem(STORAGE_KEY, this.pubkey);
|
this._signerInstance = new NostrConnectSigner({
|
||||||
return this.pubkey;
|
pool: this.nostrRelay.pool,
|
||||||
} catch (error) {
|
relays: [relay],
|
||||||
console.error('Failed to get public key from extension:', error);
|
signer: localSigner,
|
||||||
throw error;
|
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);
|
return await this.signer.signEvent(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
logout() {
|
async logout() {
|
||||||
this.pubkey = null;
|
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);
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 */
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
Reference in New Issue
Block a user