nostrmarket/static/js/index.js
Padreug 25023df8bd Add keypair rotation detection and migration feature
When a user rotates their Nostr keypair in account settings, the
merchant still holds the old key. This adds:

- key_mismatch flag on MerchantConfig (runtime, not persisted) -
  detected on each GET /api/v1/merchant by comparing account vs
  merchant pubkey
- POST /api/v1/merchant/{id}/migrate-keys endpoint that updates
  the merchant keys, republishes all stalls/products under the new
  identity, and resubscribes
- Warning banner in the UI with a "Migrate Keys" button and
  confirmation dialog
- update_merchant_keys() crud function

Orders and DM history are preserved since they reference customer
pubkeys. Old stall/product events on relays become orphaned.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-03 16:59:03 +02:00

391 lines
12 KiB
JavaScript

const nostr = window.NostrTools
window.app = Vue.createApp({
el: '#vue',
mixins: [window.windowMixin],
data: function () {
return {
activeTab: 'orders',
selectedStallFilter: null,
merchant: {},
shippingZones: [],
activeChatCustomer: '',
orderPubkey: null,
showKeys: false,
stallCount: 0,
wsConnection: null,
nostrStatus: {
connected: false,
error: null,
relays_connected: 0,
relays_total: 0
}
}
},
computed: {
nostrStatusColor: function () {
if (this.nostrStatus.connected) {
return 'green'
} else if (this.nostrStatus.warning) {
return 'orange'
}
return 'red'
},
nostrStatusLabel: function () {
return 'Connect'
}
},
methods: {
migrateKeys: async function () {
LNbits.utils
.confirmDialog(
'This will update your merchant to use your current account Nostr keypair ' +
'and republish all stalls and products under the new identity. ' +
'Existing orders and messages are preserved. Continue?'
)
.onOk(async () => {
try {
const {data} = await LNbits.api.request(
'POST',
`/nostrmarket/api/v1/merchant/${this.merchant.id}/migrate-keys`,
this.g.user.wallets[0].adminkey
)
this.merchant = data
this.$q.notify({
type: 'positive',
message: 'Merchant keys migrated and stalls republished'
})
} catch (error) {
LNbits.utils.notifyApiError(error)
}
})
},
toggleShowKeys: function () {
this.showKeys = !this.showKeys
},
toggleMerchantState: async function () {
const merchant = await this.getMerchant()
if (!merchant) {
this.$q.notify({
timeout: 5000,
type: 'warning',
message: 'Cannot fetch merchant!'
})
return
}
const message = merchant.config.active
? 'New orders will not be processed. Are you sure you want to deactivate?'
: merchant.config.restore_in_progress
? 'Merchant restore from nostr in progress. Please wait!! ' +
'Activating now can lead to duplicate order processing. Click "OK" if you want to activate anyway?'
: 'Are you sure you want activate this merchant?'
LNbits.utils.confirmDialog(message).onOk(async () => {
await this.toggleMerchant()
})
},
toggleMerchant: async function () {
try {
const {data} = await LNbits.api.request(
'PUT',
`/nostrmarket/api/v1/merchant/${this.merchant.id}/toggle`,
this.g.user.wallets[0].adminkey
)
const state = data.config.active ? 'activated' : 'disabled'
this.merchant = data
this.$q.notify({
type: 'positive',
message: `'Merchant ${state}`,
timeout: 5000
})
} catch (error) {
console.warn(error)
LNbits.utils.notifyApiError(error)
}
},
handleMerchantDeleted: function () {
this.merchant = null
this.shippingZones = []
this.activeChatCustomer = ''
this.showKeys = false
this.stallCount = 0
},
createMerchant: async function () {
try {
const payload = {
config: {}
}
const {data} = await LNbits.api.request(
'POST',
'/nostrmarket/api/v1/merchant',
this.g.user.wallets[0].adminkey,
payload
)
this.merchant = data
this.$q.notify({
type: 'positive',
message: 'Merchant Created!'
})
this.waitForNotifications()
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
getMerchant: async function () {
try {
const {data} = await LNbits.api.request(
'GET',
'/nostrmarket/api/v1/merchant',
this.g.user.wallets[0].inkey
)
this.merchant = data
return data
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
customerSelectedForOrder: function (customerPubkey) {
this.activeChatCustomer = customerPubkey
},
filterOrdersForCustomer: function (customerPubkey) {
this.orderPubkey = customerPubkey
},
showOrderDetails: async function (orderData) {
await this.$refs.orderListRef.orderSelected(
orderData.orderId,
orderData.eventId
)
},
waitForNotifications: async function () {
if (!this.merchant) return
try {
const scheme = location.protocol === 'http:' ? 'ws' : 'wss'
const port = location.port ? `:${location.port}` : ''
const wsUrl = `${scheme}://${document.domain}${port}/api/v1/ws/${this.merchant.id}`
console.log('Reconnecting to websocket: ', wsUrl)
this.wsConnection = new WebSocket(wsUrl)
this.wsConnection.onmessage = async e => {
const data = JSON.parse(e.data)
if (data.type === 'dm:0') {
this.$q.notify({
timeout: 5000,
type: 'positive',
message: 'New Order'
})
await this.$refs.directMessagesRef.handleNewMessage(data)
return
}
if (data.type === 'dm:1') {
await this.$refs.directMessagesRef.handleNewMessage(data)
await this.$refs.orderListRef.addOrder(data)
return
}
if (data.type === 'dm:2') {
const orderStatus = JSON.parse(data.dm.message)
this.$q.notify({
timeout: 5000,
type: 'positive',
message: orderStatus.message
})
if (orderStatus.paid) {
await this.$refs.orderListRef.orderPaid(orderStatus.id)
}
await this.$refs.directMessagesRef.handleNewMessage(data)
return
}
if (data.type === 'dm:-1') {
await this.$refs.directMessagesRef.handleNewMessage(data)
}
// order paid
// order shipped
}
} catch (error) {
this.$q.notify({
timeout: 5000,
type: 'warning',
message: 'Failed to watch for updates',
caption: `${error}`
})
}
},
checkNostrStatus: async function (showNotification = false) {
try {
const response = await fetch('/nostrclient/api/v1/relays')
const body = await response.json()
if (response.status === 200) {
const relaysConnected = body.filter(r => r.connected).length
if (body.length === 0) {
this.nostrStatus = {
connected: false,
error: 'No relays configured in Nostr Client',
relays_connected: 0,
relays_total: 0,
warning: true
}
} else {
this.nostrStatus = {
connected: true,
error: null,
relays_connected: relaysConnected,
relays_total: body.length
}
}
} else {
this.nostrStatus = {
connected: false,
error: body.detail,
relays_connected: 0,
relays_total: 0
}
}
if (showNotification) {
this.$q.notify({
timeout: 3000,
type: this.nostrStatus.connected ? 'positive' : 'warning',
message: this.nostrStatus.connected ? 'Connected' : 'Disconnected',
caption: this.nostrStatus.error || undefined
})
}
} catch (error) {
console.error('Failed to check nostr status:', error)
this.nostrStatus = {
connected: false,
error: error.message,
relays_connected: 0,
relays_total: 0
}
if (showNotification) {
this.$q.notify({
timeout: 5000,
type: 'negative',
message: this.nostrStatus.error
})
}
}
},
restartNostrConnection: async function () {
LNbits.utils
.confirmDialog(
'Are you sure you want to reconnect to the nostrclient extension?'
)
.onOk(async () => {
try {
this.$q.notify({
timeout: 2000,
type: 'info',
message: 'Reconnecting...'
})
await LNbits.api.request(
'PUT',
'/nostrmarket/api/v1/restart',
this.g.user.wallets[0].adminkey
)
// Check status after restart (give time for websocket to reconnect)
setTimeout(() => this.checkNostrStatus(true), 3000)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
})
},
publishNip15: async function () {
try {
const {data: stalls} = await LNbits.api.request(
'GET',
'/nostrmarket/api/v1/stall?pending=false',
this.g.user.wallets[0].inkey
)
for (const stall of stalls) {
await LNbits.api.request(
'PUT',
`/nostrmarket/api/v1/stall/${stall.id}`,
this.g.user.wallets[0].adminkey,
stall
)
}
// Fetch products from all stalls
let productCount = 0
for (const stall of stalls) {
const {data: products} = await LNbits.api.request(
'GET',
`/nostrmarket/api/v1/stall/product/${stall.id}?pending=false`,
this.g.user.wallets[0].inkey
)
for (const product of products) {
await LNbits.api.request(
'PATCH',
`/nostrmarket/api/v1/product/${product.id}`,
this.g.user.wallets[0].adminkey,
product
)
productCount++
}
}
this.$q.notify({
type: 'positive',
message: `Published ${stalls.length} stall(s) and ${productCount} product(s) to Nostr (NIP-15)`
})
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
refreshNip15: async function () {
LNbits.utils
.confirmDialog(
'This will sync your stalls and products from Nostr relays. Continue?'
)
.onOk(async () => {
try {
await LNbits.api.request(
'PUT',
'/nostrmarket/api/v1/restart',
this.g.user.wallets[0].adminkey
)
this.$q.notify({
type: 'positive',
message: 'Refreshing NIP-15 data from Nostr...'
})
} catch (error) {
LNbits.utils.notifyApiError(error)
}
})
},
deleteNip15: async function () {
LNbits.utils
.confirmDialog(
'WARNING: This will delete all your stalls and products from Nostr relays. This cannot be undone! Are you sure?'
)
.onOk(async () => {
this.$q.notify({
type: 'info',
message: 'Delete NIP-15 from Nostr not yet implemented'
})
})
},
goToProducts: function (stallId) {
this.selectedStallFilter = stallId
this.activeTab = 'products'
},
goToOrders: function (stallId) {
this.selectedStallFilter = stallId
}
},
created: async function () {
const merchant = await this.getMerchant()
if (!merchant) {
// Auto-create merchant using the account's existing Nostr keypair
await this.createMerchant()
}
await this.checkNostrStatus()
setInterval(async () => {
if (
!this.wsConnection ||
this.wsConnection.readyState !== WebSocket.OPEN
) {
await this.waitForNotifications()
}
}, 1000)
}
})