Squash merge feature/publish-metadata into main

This commit is contained in:
padreug 2025-10-30 16:19:08 +01:00
parent cc5e0dbef6
commit 875bf50765
7 changed files with 629 additions and 21 deletions

View file

@ -5,9 +5,10 @@ import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { LogoutConfirmDialog } from '@/components/ui/LogoutConfirmDialog'
import { User, LogOut, Settings, Key, Wallet, ExternalLink } from 'lucide-vue-next'
import { User, LogOut, Settings, Key, Wallet, ExternalLink, ArrowLeft } from 'lucide-vue-next'
import { auth } from '@/composables/useAuthService'
import { toast } from 'vue-sonner'
import { ProfileSettings } from '@/modules/base'
interface Props {
isOpen: boolean
@ -22,6 +23,7 @@ const emit = defineEmits<Emits>()
const userDisplay = computed(() => auth.userDisplay.value)
const showLogoutConfirm = ref(false)
const showSettings = ref(false)
// Get the API base URL from environment variables
const apiBaseUrl = import.meta.env.VITE_LNBITS_BASE_URL || ''
@ -44,24 +46,46 @@ function handleOpenWallet() {
}
function handleClose() {
showSettings.value = false
emit('update:isOpen', false)
}
function toggleSettings() {
showSettings.value = !showSettings.value
}
</script>
<template>
<Dialog :open="isOpen" @update:open="handleClose">
<DialogContent class="sm:max-w-[500px]">
<DialogContent class="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<Button
v-if="showSettings"
variant="ghost"
size="icon"
class="h-8 w-8 -ml-2"
@click="toggleSettings"
>
<ArrowLeft class="w-4 h-4" />
</Button>
<User class="w-5 h-5" />
User Profile
<span v-if="showSettings">Edit Profile</span>
<span v-else>User Profile</span>
</DialogTitle>
<DialogDescription>
Your account information and settings
<span v-if="showSettings">Update your profile information and Nostr identity</span>
<span v-else>Your account information and settings</span>
</DialogDescription>
</DialogHeader>
<div v-if="userDisplay" class="space-y-6">
<!-- Profile Settings View -->
<div v-if="showSettings">
<ProfileSettings />
</div>
<!-- Profile Info View -->
<div v-else-if="userDisplay" class="space-y-6">
<!-- User Info Card -->
<Card>
<CardHeader>
@ -116,10 +140,10 @@ function handleClose() {
Open Wallet
<ExternalLink class="w-3 h-3 ml-auto" />
</Button>
<Button variant="outline" class="w-full justify-start gap-2">
<Button variant="outline" @click="toggleSettings" class="w-full justify-start gap-2">
<Settings class="w-4 h-4" />
Account Settings
Edit Profile
</Button>
<Button variant="outline" class="w-full justify-start gap-2">

View file

@ -137,6 +137,9 @@ export const SERVICE_TOKENS = {
PROFILE_SERVICE: Symbol('profileService'),
REACTION_SERVICE: Symbol('reactionService'),
// Nostr metadata services
NOSTR_METADATA_SERVICE: Symbol('nostrMetadataService'),
// Events services
EVENTS_SERVICE: Symbol('eventsService'),

View file

@ -2,7 +2,9 @@
import { ref, computed } from 'vue'
import { BaseService } from '@/core/base/BaseService'
import { eventBus } from '@/core/event-bus'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { LoginCredentials, RegisterData, User } from '@/lib/api/lnbits'
import type { NostrMetadataService } from '../nostr/nostr-metadata-service'
export class AuthService extends BaseService {
// Service metadata
@ -79,16 +81,19 @@ export class AuthService extends BaseService {
async login(credentials: LoginCredentials): Promise<void> {
this.isLoading.value = true
try {
await this.lnbitsAPI.login(credentials)
const userData = await this.lnbitsAPI.getCurrentUser()
this.user.value = userData
this.isAuthenticated.value = true
eventBus.emit('auth:login', { user: userData }, 'auth-service')
// Auto-broadcast Nostr metadata on login
this.broadcastNostrMetadata()
} catch (error) {
const err = this.handleError(error, 'login')
eventBus.emit('auth:login-failed', { error: err }, 'auth-service')
@ -100,16 +105,19 @@ export class AuthService extends BaseService {
async register(data: RegisterData): Promise<void> {
this.isLoading.value = true
try {
await this.lnbitsAPI.register(data)
const userData = await this.lnbitsAPI.getCurrentUser()
this.user.value = userData
this.isAuthenticated.value = true
eventBus.emit('auth:login', { user: userData }, 'auth-service')
// Auto-broadcast Nostr metadata on registration
this.broadcastNostrMetadata()
} catch (error) {
const err = this.handleError(error, 'register')
eventBus.emit('auth:login-failed', { error: err }, 'auth-service')
@ -156,7 +164,19 @@ export class AuthService extends BaseService {
try {
this.isLoading.value = true
const updatedUser = await this.lnbitsAPI.updateProfile(data)
this.user.value = updatedUser
// Preserve prvkey and pubkey from existing user since /auth/update doesn't return them
this.user.value = {
...updatedUser,
pubkey: this.user.value?.pubkey || updatedUser.pubkey,
prvkey: this.user.value?.prvkey || updatedUser.prvkey
}
// Auto-broadcast Nostr metadata when profile is updated
// Note: ProfileSettings component will also manually broadcast,
// but this ensures metadata stays in sync even if updated elsewhere
this.broadcastNostrMetadata()
} catch (error) {
const err = this.handleError(error, 'updateProfile')
throw err
@ -165,6 +185,26 @@ export class AuthService extends BaseService {
}
}
/**
* Broadcast user metadata to Nostr relays (NIP-01 kind 0)
* Called automatically on login, registration, and profile updates
*/
private async broadcastNostrMetadata(): Promise<void> {
try {
const metadataService = injectService<NostrMetadataService>(SERVICE_TOKENS.NOSTR_METADATA_SERVICE)
if (metadataService && this.user.value?.pubkey) {
// Broadcast in background - don't block login/update
metadataService.publishMetadata().catch(error => {
console.warn('Failed to broadcast Nostr metadata:', error)
// Don't throw - this is a non-critical background operation
})
}
} catch (error) {
// If service isn't available yet, silently skip
console.debug('Nostr metadata service not yet available')
}
}
/**
* Cleanup when service is disposed
*/

View file

@ -0,0 +1,325 @@
<template>
<div class="space-y-6">
<div>
<h3 class="text-lg font-medium">Profile Settings</h3>
<p class="text-sm text-muted-foreground">
Manage your profile information and Nostr identity
</p>
</div>
<Separator />
<form @submit="onSubmit" class="space-y-6">
<!-- Profile Picture -->
<FormField name="picture">
<FormItem>
<FormLabel>Profile Picture</FormLabel>
<FormDescription>
Upload a profile picture. This will be published to your Nostr profile.
</FormDescription>
<div class="flex items-center gap-4">
<!-- Current picture preview -->
<div v-if="currentPictureUrl" class="relative">
<img
:src="currentPictureUrl"
alt="Profile picture"
class="h-20 w-20 rounded-full object-cover border-2 border-border"
/>
</div>
<div v-else class="h-20 w-20 rounded-full bg-muted flex items-center justify-center border-2 border-border">
<User class="h-10 w-10 text-muted-foreground" />
</div>
<!-- Upload component -->
<ImageUpload
v-model="uploadedPicture"
:multiple="false"
:max-files="1"
:max-size-mb="5"
:disabled="isUpdating"
:allow-camera="true"
placeholder="Upload picture"
accept="image/*"
/>
</div>
<FormMessage />
</FormItem>
</FormField>
<!-- Username (Read-only for now) -->
<!-- TODO: Enable username editing in the future
Note: Changing username would require updating:
- LNURLp extension: Lightning address (lnurlp)
- Nostr extension: NIP-05 identifier
This needs to be coordinated across multiple LNbits extensions
-->
<FormField v-slot="{ componentField }" name="username">
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input
placeholder="Enter username"
:disabled="true"
v-bind="componentField"
/>
</FormControl>
<FormDescription>
Your unique username. This is used for your NIP-05 identifier ({{ nip05Preview }}) and Lightning Address. Username changes are not currently supported.
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Display Name (optional) -->
<FormField v-slot="{ componentField }" name="display_name">
<FormItem>
<FormLabel>Display Name (optional)</FormLabel>
<FormControl>
<Input
placeholder="Enter display name"
:disabled="isUpdating"
v-bind="componentField"
/>
</FormControl>
<FormDescription>
A friendly display name shown on your profile
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Lightning Address / NIP-05 (read-only info) -->
<div class="rounded-lg border p-4 space-y-2 bg-muted/50">
<h4 class="text-sm font-medium">Nostr Identity</h4>
<div class="space-y-1 text-sm">
<div class="flex items-center gap-2">
<Zap class="h-4 w-4 text-yellow-500" />
<span class="text-muted-foreground">Lightning Address:</span>
<code class="text-xs bg-background px-2 py-1 rounded">{{ lightningAddress }}</code>
</div>
<div class="flex items-center gap-2">
<Hash class="h-4 w-4 text-purple-500" />
<span class="text-muted-foreground">NIP-05:</span>
<code class="text-xs bg-background px-2 py-1 rounded">{{ nip05Preview }}</code>
</div>
</div>
<p class="text-xs text-muted-foreground mt-2">
These identifiers are automatically derived from your username
</p>
</div>
<!-- Error Display -->
<div v-if="updateError" class="text-sm text-destructive">
{{ updateError }}
</div>
<!-- Success Display -->
<div v-if="updateSuccess" class="text-sm text-green-600">
Profile updated successfully!
</div>
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-3">
<Button
type="submit"
:disabled="isUpdating || !isFormValid"
class="flex-1"
>
<span v-if="isUpdating">Updating...</span>
<span v-else>Update Profile</span>
</Button>
<Button
type="button"
variant="outline"
:disabled="isBroadcasting"
@click="broadcastMetadata"
class="flex-1"
>
<Radio class="mr-2 h-4 w-4" :class="{ 'animate-pulse': isBroadcasting }" />
<span v-if="isBroadcasting">Broadcasting...</span>
<span v-else>Broadcast to Nostr</span>
</Button>
</div>
<p class="text-xs text-muted-foreground">
Your profile is automatically broadcast to Nostr when you update it or log in.
Use the "Broadcast to Nostr" button to manually re-broadcast your profile.
</p>
</form>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Separator } from '@/components/ui/separator'
import { User, Zap, Hash, Radio } from 'lucide-vue-next'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import ImageUpload from './ImageUpload.vue'
import { useAuth } from '@/composables/useAuthService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ImageUploadService } from '../services/ImageUploadService'
import type { NostrMetadataService } from '../nostr/nostr-metadata-service'
import { useToast } from '@/core/composables/useToast'
// Services
const { user, updateProfile } = useAuth()
const imageService = injectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE)
const metadataService = injectService<NostrMetadataService>(SERVICE_TOKENS.NOSTR_METADATA_SERVICE)
const toast = useToast()
// Local state
const isUpdating = ref(false)
const isBroadcasting = ref(false)
const updateError = ref<string | null>(null)
const updateSuccess = ref(false)
const uploadedPicture = ref<any[]>([])
// Get current user data
const currentUsername = computed(() => user.value?.username || '')
const currentDisplayName = computed(() => user.value?.extra?.display_name || '')
const currentPictureUrl = computed(() => user.value?.extra?.picture || '')
// Lightning domain
const lightningDomain = computed(() =>
import.meta.env.VITE_LIGHTNING_DOMAIN || window.location.hostname
)
// Computed previews
const nip05Preview = computed(() => {
const username = form.values.username || currentUsername.value || 'username'
return `${username}@${lightningDomain.value}`
})
const lightningAddress = computed(() => nip05Preview.value)
// Form schema
const profileFormSchema = toTypedSchema(z.object({
username: z.string()
.min(3, "Username must be at least 3 characters")
.max(30, "Username must be less than 30 characters")
.regex(/^[a-z0-9_-]+$/i, "Username can only contain letters, numbers, hyphens, and underscores"),
display_name: z.string()
.max(50, "Display name must be less than 50 characters")
.optional(),
picture: z.string().url("Invalid picture URL").optional().or(z.literal(''))
}))
// Form setup
const form = useForm({
validationSchema: profileFormSchema,
initialValues: {
username: currentUsername.value,
display_name: currentDisplayName.value,
picture: currentPictureUrl.value
}
})
// Watch for user changes and reset form
watch(user, (newUser) => {
if (newUser) {
form.resetForm({
values: {
username: newUser.username || '',
display_name: newUser.extra?.display_name || '',
picture: newUser.extra?.picture || ''
}
})
}
}, { immediate: true })
const { meta } = form
const isFormValid = computed(() => meta.value.valid)
// Form submit handler
const onSubmit = form.handleSubmit(async (values) => {
await updateUserProfile(values)
})
// Update user profile
const updateUserProfile = async (formData: any) => {
isUpdating.value = true
updateError.value = null
updateSuccess.value = false
try {
// Get uploaded picture URL if a new picture was uploaded
let pictureUrl = formData.picture || currentPictureUrl.value
if (uploadedPicture.value && uploadedPicture.value.length > 0) {
const img = uploadedPicture.value[0]
if (img.alias) {
const imageUrl = imageService.getImageUrl(img.alias)
if (imageUrl) {
pictureUrl = imageUrl
}
}
}
// Prepare update data
const updateData = {
user_id: user.value?.id,
username: formData.username,
extra: {
display_name: formData.display_name || '',
picture: pictureUrl || ''
}
}
// Update profile via AuthService (which updates LNbits)
await updateProfile(updateData)
// Broadcast to Nostr automatically
try {
await metadataService.publishMetadata()
toast.success('Profile updated and broadcast to Nostr!')
} catch (nostrError) {
console.error('Failed to broadcast to Nostr:', nostrError)
toast.warning('Profile updated, but failed to broadcast to Nostr')
}
updateSuccess.value = true
// Clear success message after 3 seconds
setTimeout(() => {
updateSuccess.value = false
}, 3000)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to update profile'
console.error('Error updating profile:', error)
updateError.value = errorMessage
toast.error(`Failed to update profile: ${errorMessage}`)
} finally {
isUpdating.value = false
}
}
// Manually broadcast metadata to Nostr
const broadcastMetadata = async () => {
isBroadcasting.value = true
try {
const result = await metadataService.publishMetadata()
toast.success(`Profile broadcast to ${result.success}/${result.total} relays!`)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to broadcast metadata'
console.error('Error broadcasting metadata:', error)
toast.error(`Failed to broadcast: ${errorMessage}`)
} finally {
isBroadcasting.value = false
}
}
</script>

View file

@ -0,0 +1,39 @@
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { NostrMetadataService, NostrMetadata } from '../nostr/nostr-metadata-service'
/**
* Composable for accessing Nostr metadata service
*
* @example
* ```ts
* const { publishMetadata, getMetadata } = useNostrMetadata()
*
* // Get current metadata
* const metadata = getMetadata()
*
* // Publish metadata to Nostr relays
* await publishMetadata()
* ```
*/
export function useNostrMetadata() {
const metadataService = injectService<NostrMetadataService>(SERVICE_TOKENS.NOSTR_METADATA_SERVICE)
/**
* Publish user metadata to Nostr relays (NIP-01 kind 0)
*/
const publishMetadata = async (): Promise<{ success: number; total: number }> => {
return await metadataService.publishMetadata()
}
/**
* Get current user's Nostr metadata
*/
const getMetadata = (): NostrMetadata => {
return metadataService.getMetadata()
}
return {
publishMetadata,
getMetadata
}
}

View file

@ -2,6 +2,7 @@ import type { App } from 'vue'
import type { ModulePlugin } from '@/core/types'
import { container, SERVICE_TOKENS } from '@/core/di-container'
import { relayHub } from './nostr/relay-hub'
import { NostrMetadataService } from './nostr/nostr-metadata-service'
// Import auth services
import { auth } from './auth/auth-service'
@ -20,11 +21,13 @@ import { ImageUploadService } from './services/ImageUploadService'
// Import components
import ImageUpload from './components/ImageUpload.vue'
import ProfileSettings from './components/ProfileSettings.vue'
// Create service instances
const invoiceService = new InvoiceService()
const lnbitsAPI = new LnbitsAPI()
const imageUploadService = new ImageUploadService()
const nostrMetadataService = new NostrMetadataService()
/**
* Base Module Plugin
@ -39,7 +42,8 @@ export const baseModule: ModulePlugin = {
// Register core Nostr services
container.provide(SERVICE_TOKENS.RELAY_HUB, relayHub)
container.provide(SERVICE_TOKENS.NOSTR_METADATA_SERVICE, nostrMetadataService)
// Register auth service
container.provide(SERVICE_TOKENS.AUTH_SERVICE, auth)
@ -98,6 +102,10 @@ export const baseModule: ModulePlugin = {
waitForDependencies: true, // ImageUploadService depends on ToastService
maxRetries: 3
})
await nostrMetadataService.initialize({
waitForDependencies: true, // NostrMetadataService depends on AuthService and RelayHub
maxRetries: 3
})
// InvoiceService doesn't need initialization as it's not a BaseService
console.log('✅ Base module installed successfully')
@ -114,13 +122,15 @@ export const baseModule: ModulePlugin = {
await storageService.dispose()
await toastService.dispose()
await imageUploadService.dispose()
await nostrMetadataService.dispose()
// InvoiceService doesn't need disposal as it's not a BaseService
await lnbitsAPI.dispose()
// Remove services from DI container
container.remove(SERVICE_TOKENS.LNBITS_API)
container.remove(SERVICE_TOKENS.INVOICE_SERVICE)
container.remove(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE)
container.remove(SERVICE_TOKENS.NOSTR_METADATA_SERVICE)
console.log('✅ Base module uninstalled')
},
@ -134,7 +144,8 @@ export const baseModule: ModulePlugin = {
toastService,
invoiceService,
pwaService,
imageUploadService
imageUploadService,
nostrMetadataService
},
// No routes - base module is pure infrastructure
@ -142,8 +153,12 @@ export const baseModule: ModulePlugin = {
// Export components for use by other modules
components: {
ImageUpload
ImageUpload,
ProfileSettings
}
}
// Export components as named exports for direct import
export { ImageUpload, ProfileSettings }
export default baseModule

View file

@ -0,0 +1,162 @@
import { BaseService } from '@/core/base/BaseService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { finalizeEvent, type EventTemplate } from 'nostr-tools'
import type { AuthService } from '@/modules/base/auth/auth-service'
import type { RelayHub } from '@/modules/base/nostr/relay-hub'
/**
* Nostr User Metadata (NIP-01 kind 0)
* https://github.com/nostr-protocol/nips/blob/master/01.md
*/
export interface NostrMetadata {
name?: string // Display name (from username)
display_name?: string // Alternative display name
about?: string // Bio/description
picture?: string // Profile picture URL
banner?: string // Profile banner URL
nip05?: string // NIP-05 identifier (username@domain)
lud16?: string // Lightning Address (same as nip05)
website?: string // Personal website
}
/**
* Service for publishing and managing Nostr user metadata (NIP-01 kind 0)
*
* This service handles:
* - Publishing user profile metadata to Nostr relays
* - Syncing LNbits user data with Nostr profile
* - Auto-broadcasting metadata on login and profile updates
*/
export class NostrMetadataService extends BaseService {
protected readonly metadata = {
name: 'NostrMetadataService',
version: '1.0.0',
dependencies: ['AuthService', 'RelayHub']
}
protected authService: AuthService | null = null
protected relayHub: RelayHub | null = null
protected async onInitialize(): Promise<void> {
console.log('NostrMetadataService: Starting initialization...')
this.authService = injectService<AuthService>(SERVICE_TOKENS.AUTH_SERVICE)
this.relayHub = injectService<RelayHub>(SERVICE_TOKENS.RELAY_HUB)
if (!this.authService) {
throw new Error('AuthService not available')
}
if (!this.relayHub) {
throw new Error('RelayHub service not available')
}
console.log('NostrMetadataService: Initialization complete')
}
/**
* Build Nostr metadata from LNbits user data
*/
private buildMetadata(): NostrMetadata {
const user = this.authService?.user.value
if (!user) {
throw new Error('No authenticated user')
}
const lightningDomain = import.meta.env.VITE_LIGHTNING_DOMAIN || window.location.hostname
const username = user.username || user.id.slice(0, 8)
const metadata: NostrMetadata = {
name: username,
nip05: `${username}@${lightningDomain}`,
lud16: `${username}@${lightningDomain}`
}
// Add optional fields from user.extra if they exist
if (user.extra?.display_name) {
metadata.display_name = user.extra.display_name
}
if (user.extra?.picture) {
metadata.picture = user.extra.picture
}
return metadata
}
/**
* Publish user metadata to Nostr relays (NIP-01 kind 0)
*
* This creates a replaceable event that updates the user's profile.
* Only the latest kind 0 event for a given pubkey is kept by relays.
*/
async publishMetadata(): Promise<{ success: number; total: number }> {
if (!this.authService?.isAuthenticated.value) {
throw new Error('Must be authenticated to publish metadata')
}
if (!this.relayHub?.isConnected.value) {
throw new Error('Not connected to relays')
}
const user = this.authService.user.value
if (!user?.prvkey) {
throw new Error('User private key not available')
}
try {
const metadata = this.buildMetadata()
console.log('📤 Publishing Nostr metadata (kind 0):', metadata)
// Create kind 0 event (user metadata)
// Content is JSON-stringified metadata
const eventTemplate: EventTemplate = {
kind: 0,
content: JSON.stringify(metadata),
tags: [],
created_at: Math.floor(Date.now() / 1000)
}
// Sign the event
const privkeyBytes = this.hexToUint8Array(user.prvkey)
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
console.log('✅ Metadata event signed:', signedEvent.id)
console.log('📋 Full signed event:', JSON.stringify(signedEvent, null, 2))
// Publish to all connected relays
const result = await this.relayHub.publishEvent(signedEvent)
console.log(`✅ Metadata published to ${result.success}/${result.total} relays`)
return result
} catch (error) {
console.error('Failed to publish metadata:', error)
throw error
}
}
/**
* Get current user's Nostr metadata
*/
getMetadata(): NostrMetadata {
return this.buildMetadata()
}
/**
* Helper function to convert hex string to Uint8Array
*/
private hexToUint8Array(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
}
return bytes
}
protected async onDestroy(): Promise<void> {
// Cleanup if needed
}
}