feat: Implement comprehensive Nostr identity and social features

## Core Identity Management
- Add secure key generation and import functionality
- Implement AES-GCM encryption with PBKDF2 key derivation
- Create password-protected identity storage
- Add browser-compatible crypto utilities (no Buffer dependency)

## User Interface
- Build identity management dialog with tabs for setup and profile
- Add navbar integration with user dropdown and mobile support
- Create password unlock dialog for encrypted identities
- Integrate vue-sonner for toast notifications

## Nostr Protocol Integration
- Implement event creation (notes, reactions, profiles, contacts)
- Add reply thread detection and engagement metrics
- Create social interaction composables for publishing
- Support multi-relay publishing with failure handling
- Add profile fetching and caching system

## Security Features
- Web Crypto API with 100k PBKDF2 iterations
- Secure random salt and IV generation
- Automatic password prompts for encrypted storage
- Legacy support for unencrypted identities

## Technical Improvements
- Replace all Buffer usage with browser-native APIs
- Add comprehensive error handling and validation
- Implement reactive state management with Vue composables
- Create reusable crypto utility functions

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
padreug 2025-07-02 16:25:20 +02:00
parent d3e8b23c86
commit ee7eb461c4
12 changed files with 1777 additions and 21 deletions

View file

@ -3,8 +3,12 @@ import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTheme } from '@/components/theme-provider'
import { Button } from '@/components/ui/button'
import { Zap, Sun, Moon, Menu, X } from 'lucide-vue-next'
import { Badge } from '@/components/ui/badge'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { Zap, Sun, Moon, Menu, X, User, Key, LogOut } from 'lucide-vue-next'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
import IdentityDialog from '@/components/nostr/IdentityDialog.vue'
import { identity } from '@/composables/useIdentity'
interface NavigationItem {
name: string
@ -14,6 +18,7 @@ interface NavigationItem {
const { t } = useI18n()
const { theme, setTheme } = useTheme()
const isOpen = ref(false)
const showIdentityDialog = ref(false)
const navigation = computed<NavigationItem[]>(() => [
{ name: t('nav.home'), href: '/' },
@ -28,6 +33,10 @@ const toggleMenu = () => {
const toggleTheme = () => {
setTheme(theme.value === 'dark' ? 'light' : 'dark')
}
const openIdentityDialog = () => {
showIdentityDialog.value = true
}
</script>
<template>
@ -51,8 +60,41 @@ const toggleTheme = () => {
</div>
</div>
<!-- Theme Toggle and Language -->
<!-- Theme Toggle, Language, and Identity -->
<div class="flex items-center gap-2">
<!-- Identity Management -->
<div class="hidden sm:block">
<DropdownMenu v-if="identity.isAuthenticated.value">
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" class="gap-2">
<User class="h-4 w-4" />
<span class="max-w-24 truncate">{{ identity.profileDisplay.value?.name || 'Anonymous' }}</span>
<Badge variant="secondary" class="text-xs">Connected</Badge>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-56">
<DropdownMenuItem @click="openIdentityDialog" class="gap-2">
<User class="h-4 w-4" />
Profile
</DropdownMenuItem>
<DropdownMenuItem @click="openIdentityDialog" class="gap-2">
<Key class="h-4 w-4" />
Keys
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem @click="identity.signOut()" class="gap-2 text-destructive">
<LogOut class="h-4 w-4" />
Sign Out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button v-else variant="outline" size="sm" @click="openIdentityDialog" class="gap-2">
<User class="h-4 w-4" />
Connect Identity
</Button>
</div>
<!-- Hide theme toggle on mobile -->
<Button variant="ghost" size="icon" @click="toggleTheme"
class="hidden sm:inline-flex text-foreground hover:text-foreground/90 hover:bg-accent">
@ -79,8 +121,33 @@ const toggleTheme = () => {
<div v-show="isOpen"
class="absolute left-0 right-0 top-14 z-[100] border-b bg-background/95 backdrop-blur-sm md:hidden">
<div class="space-y-1 p-4">
<!-- Identity in mobile menu -->
<div class="mb-4 px-2">
<Button v-if="!identity.isAuthenticated.value" variant="outline" size="sm" @click="openIdentityDialog; isOpen = false" class="w-full gap-2">
<User class="h-4 w-4" />
Connect Identity
</Button>
<div v-else class="space-y-2">
<div class="flex items-center gap-2 px-2 py-1">
<User class="h-4 w-4" />
<span class="text-sm font-medium">{{ identity.profileDisplay.value?.name || 'Anonymous' }}</span>
<Badge variant="secondary" class="text-xs ml-auto">Connected</Badge>
</div>
<div class="space-y-1">
<Button variant="ghost" size="sm" @click="openIdentityDialog; isOpen = false" class="w-full justify-start gap-2">
<User class="h-4 w-4" />
Profile
</Button>
<Button variant="ghost" size="sm" @click="identity.signOut(); isOpen = false" class="w-full justify-start gap-2 text-destructive">
<LogOut class="h-4 w-4" />
Sign Out
</Button>
</div>
</div>
</div>
<!-- Add theme and language toggles to mobile menu -->
<div class="flex items-center justify-between mb-4 px-2">
<div class="flex items-center justify-between mb-4 px-2 border-t pt-4">
<Button variant="ghost" size="icon" @click="toggleTheme"
class="text-foreground hover:text-foreground/90 hover:bg-accent">
<Sun v-if="theme === 'dark'" class="h-5 w-5" />
@ -98,5 +165,8 @@ const toggleTheme = () => {
</router-link>
</div>
</div>
<!-- Identity Dialog -->
<IdentityDialog v-model:is-open="showIdentityDialog" />
</nav>
</template>

View file

@ -0,0 +1,453 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Key, Download, Upload, Copy, Check } from 'lucide-vue-next'
import { identity } from '@/composables/useIdentity'
import { toast } from 'vue-sonner'
interface Props {
isOpen: boolean
}
interface Emits {
(e: 'update:isOpen', value: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// Form states
const importKey = ref('')
const usePassword = ref(false)
const password = ref('')
const confirmPassword = ref('')
const showPrivateKey = ref(false)
const copied = ref(false)
// Profile form
const profileForm = ref({
name: '',
display_name: '',
about: '',
picture: '',
website: '',
lud16: ''
})
const passwordsMatch = computed(() => {
if (!usePassword.value) return true
return password.value === confirmPassword.value
})
const canGenerate = computed(() => {
if (!usePassword.value) return true
return password.value.length >= 6 && passwordsMatch.value
})
const canImport = computed(() => {
return importKey.value.trim().length > 0 && canGenerate.value
})
async function handleGenerate() {
try {
const pwd = usePassword.value ? password.value : undefined
await identity.generateNewIdentity(pwd)
// Initialize profile form with current data
const profile = identity.currentProfile.value
if (profile) {
profileForm.value = { ...profile }
}
toast.success('Identity generated successfully!')
} catch (error) {
console.error('Failed to generate identity:', error)
toast.error('Failed to generate identity')
}
}
async function handleImport() {
try {
const pwd = usePassword.value ? password.value : undefined
await identity.importIdentity(importKey.value.trim(), pwd)
// Initialize profile form with current data
const profile = identity.currentProfile.value
if (profile) {
profileForm.value = { ...profile }
}
toast.success('Identity imported successfully!')
} catch (error) {
console.error('Failed to import identity:', error)
toast.error('Failed to import identity')
}
}
async function handleUpdateProfile() {
try {
// Filter out empty values
const profileData = Object.fromEntries(
Object.entries(profileForm.value).filter(([_, value]) => value.trim() !== '')
)
await identity.updateProfile(profileData)
toast.success('Profile updated successfully!')
} catch (error) {
console.error('Failed to update profile:', error)
toast.error('Failed to update profile')
}
}
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text)
copied.value = true
setTimeout(() => copied.value = false, 2000)
toast.success('Copied to clipboard!')
} catch (error) {
toast.error('Failed to copy to clipboard')
}
}
function handleClose() {
emit('update:isOpen', false)
// Reset forms
importKey.value = ''
password.value = ''
confirmPassword.value = ''
usePassword.value = false
showPrivateKey.value = false
}
// Initialize profile form when dialog opens
function initializeProfileForm() {
const profile = identity.currentProfile.value
if (profile) {
profileForm.value = {
name: profile.name || '',
display_name: profile.display_name || '',
about: profile.about || '',
picture: profile.picture || '',
website: profile.website || '',
lud16: profile.lud16 || ''
}
}
}
</script>
<template>
<Dialog :open="isOpen" @update:open="handleClose">
<DialogContent class="sm:max-w-[600px] max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<Key class="w-5 h-5" />
Nostr Identity
</DialogTitle>
<DialogDescription>
Manage your Nostr identity and profile
</DialogDescription>
</DialogHeader>
<Tabs default-value="identity" class="w-full" @update:model-value="initializeProfileForm">
<TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="identity">Identity</TabsTrigger>
<TabsTrigger value="profile" :disabled="!identity.isAuthenticated.value">
Profile
</TabsTrigger>
</TabsList>
<TabsContent value="identity" class="space-y-4">
<!-- Current Identity Display -->
<div v-if="identity.isAuthenticated.value" class="space-y-4">
<Card>
<CardHeader>
<CardTitle class="text-lg">Current Identity</CardTitle>
<CardDescription>Your active Nostr identity</CardDescription>
</CardHeader>
<CardContent class="space-y-3">
<div class="space-y-2">
<Label class="text-sm font-medium">Public Key (npub)</Label>
<div class="flex items-center gap-2">
<Input
:model-value="identity.identityInfo.value?.npub || ''"
readonly
class="font-mono text-xs"
/>
<Button
size="sm"
variant="outline"
@click="copyToClipboard(identity.identityInfo.value?.npub || '')"
>
<Copy class="w-4 h-4" />
</Button>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<Label class="text-sm font-medium">Private Key (nsec)</Label>
<Button
size="sm"
variant="ghost"
@click="showPrivateKey = !showPrivateKey"
>
{{ showPrivateKey ? 'Hide' : 'Show' }}
</Button>
</div>
<div class="flex items-center gap-2">
<Input
:type="showPrivateKey ? 'text' : 'password'"
:model-value="identity.currentIdentity.value?.nsec || ''"
readonly
class="font-mono text-xs"
/>
<Button
size="sm"
variant="outline"
@click="copyToClipboard(identity.currentIdentity.value?.nsec || '')"
>
<component :is="copied ? Check : Copy" class="w-4 h-4" />
</Button>
</div>
<p class="text-xs text-muted-foreground">
Keep your private key safe! Anyone with access can control your identity.
</p>
</div>
<Button variant="destructive" size="sm" @click="identity.signOut()">
Sign Out
</Button>
</CardContent>
</Card>
</div>
<!-- Identity Setup -->
<div v-else class="space-y-4">
<Tabs default-value="generate" class="w-full">
<TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="generate">
<Download class="w-4 h-4 mr-2" />
Generate New
</TabsTrigger>
<TabsTrigger value="import">
<Upload class="w-4 h-4 mr-2" />
Import Existing
</TabsTrigger>
</TabsList>
<TabsContent value="generate" class="space-y-4">
<Card>
<CardHeader>
<CardTitle>Generate New Identity</CardTitle>
<CardDescription>
Create a new Nostr identity with a fresh keypair
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex items-center space-x-2">
<input
id="use-password"
type="checkbox"
v-model="usePassword"
class="rounded"
/>
<Label for="use-password">Encrypt with password</Label>
</div>
<div v-if="usePassword" class="space-y-3">
<div>
<Label for="password">Password</Label>
<Input
id="password"
type="password"
v-model="password"
placeholder="Enter password (min 6 characters)"
/>
</div>
<div>
<Label for="confirm-password">Confirm Password</Label>
<Input
id="confirm-password"
type="password"
v-model="confirmPassword"
placeholder="Confirm password"
:class="{ 'border-destructive': !passwordsMatch && confirmPassword.length > 0 }"
/>
<p v-if="!passwordsMatch && confirmPassword.length > 0" class="text-xs text-destructive mt-1">
Passwords do not match
</p>
</div>
</div>
<Button
@click="handleGenerate"
:disabled="!canGenerate || identity.isLoading.value"
class="w-full"
>
Generate Identity
</Button>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="import" class="space-y-4">
<Card>
<CardHeader>
<CardTitle>Import Existing Identity</CardTitle>
<CardDescription>
Import your existing Nostr identity using your private key
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div>
<Label for="import-key">Private Key</Label>
<Textarea
id="import-key"
v-model="importKey"
placeholder="Enter your private key (hex format or nsec...)"
class="font-mono text-xs"
/>
</div>
<div class="flex items-center space-x-2">
<input
id="use-password-import"
type="checkbox"
v-model="usePassword"
class="rounded"
/>
<Label for="use-password-import">Encrypt with password</Label>
</div>
<div v-if="usePassword" class="space-y-3">
<div>
<Label for="password-import">Password</Label>
<Input
id="password-import"
type="password"
v-model="password"
placeholder="Enter password (min 6 characters)"
/>
</div>
<div>
<Label for="confirm-password-import">Confirm Password</Label>
<Input
id="confirm-password-import"
type="password"
v-model="confirmPassword"
placeholder="Confirm password"
:class="{ 'border-destructive': !passwordsMatch && confirmPassword.length > 0 }"
/>
</div>
</div>
<Button
@click="handleImport"
:disabled="!canImport || identity.isLoading.value"
class="w-full"
>
Import Identity
</Button>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</TabsContent>
<TabsContent value="profile" class="space-y-4">
<Card>
<CardHeader>
<CardTitle>Profile Information</CardTitle>
<CardDescription>
Update your public Nostr profile information
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<Label for="name">Name</Label>
<Input
id="name"
v-model="profileForm.name"
placeholder="Your name"
/>
</div>
<div>
<Label for="display-name">Display Name</Label>
<Input
id="display-name"
v-model="profileForm.display_name"
placeholder="Display name"
/>
</div>
</div>
<div>
<Label for="about">About</Label>
<Textarea
id="about"
v-model="profileForm.about"
placeholder="Tell people about yourself..."
rows="3"
/>
</div>
<div>
<Label for="picture">Picture URL</Label>
<Input
id="picture"
v-model="profileForm.picture"
placeholder="https://example.com/your-avatar.jpg"
type="url"
/>
</div>
<div>
<Label for="website">Website</Label>
<Input
id="website"
v-model="profileForm.website"
placeholder="https://your-website.com"
type="url"
/>
</div>
<div>
<Label for="lightning">Lightning Address</Label>
<Input
id="lightning"
v-model="profileForm.lud16"
placeholder="you@getalby.com"
/>
<p class="text-xs text-muted-foreground mt-1">
Lightning address for receiving zaps
</p>
</div>
<Button
@click="handleUpdateProfile"
:disabled="identity.isLoading.value"
class="w-full"
>
Update Profile
</Button>
</CardContent>
</Card>
</TabsContent>
</Tabs>
<div v-if="identity.error.value" class="text-sm text-destructive">
{{ identity.error.value }}
</div>
</DialogContent>
</Dialog>
</template>

View file

@ -0,0 +1,105 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Lock } from 'lucide-vue-next'
interface Props {
isOpen: boolean
title?: string
description?: string
}
interface Emits {
(e: 'update:isOpen', value: boolean): void
(e: 'password', password: string): void
(e: 'cancel'): void
}
const props = withDefaults(defineProps<Props>(), {
title: 'Enter Password',
description: 'Your identity is encrypted. Please enter your password to continue.'
})
const emit = defineEmits<Emits>()
const password = ref('')
const isLoading = ref(false)
const error = ref('')
function handleSubmit() {
if (!password.value.trim()) {
error.value = 'Password is required'
return
}
error.value = ''
emit('password', password.value)
}
function handleCancel() {
emit('cancel')
handleClose()
}
function handleClose() {
emit('update:isOpen', false)
// Reset form
password.value = ''
error.value = ''
isLoading.value = false
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
handleSubmit()
}
}
</script>
<template>
<Dialog :open="isOpen" @update:open="handleClose">
<DialogContent class="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<Lock class="w-5 h-5" />
{{ title }}
</DialogTitle>
<DialogDescription>
{{ description }}
</DialogDescription>
</DialogHeader>
<div class="grid gap-4 py-4">
<div class="space-y-2">
<Label for="password">Password</Label>
<Input
id="password"
type="password"
v-model="password"
placeholder="Enter your password"
:disabled="isLoading"
@keydown="handleKeydown"
class="w-full"
autofocus
/>
<p v-if="error" class="text-sm text-destructive">
{{ error }}
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="handleCancel" :disabled="isLoading">
Cancel
</Button>
<Button @click="handleSubmit" :disabled="isLoading || !password.trim()">
<span v-if="isLoading" class="animate-spin mr-2"></span>
Unlock
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>