web-app/src/components/nostr/IdentityDialog.vue
padreug 8a9ffc5918 feat: Implement secure VAPID key generation for push notifications
- Replace random key generation with the web-push library for generating cryptographically secure VAPID keys.
- Update console output to guide users on adding keys to their environment configuration.
- Enhance error handling for VAPID key generation issues.
- Add web-push dependency to package.json and package-lock.json for proper functionality.
2025-07-12 18:10:33 +02:00

467 lines
16 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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 { 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
}
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 = {
name: profile.name || '',
display_name: profile.display_name || '',
about: profile.about || '',
picture: profile.picture || '',
website: profile.website || '',
lud16: profile.lud16 || ''
}
}
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 = {
name: profile.name || '',
display_name: profile.display_name || '',
about: profile.about || '',
picture: profile.picture || '',
website: profile.website || '',
lud16: profile.lud16 || ''
}
}
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="w-[95vw] sm:max-w-[600px] max-h-[90vh] overflow-y-auto p-4 sm:p-6">
<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>