Files
indee-demo/src/composables/useAccounts.ts
Dorian 2a16802404 feat: add KeysModal for managing private key accounts
- Introduced a new KeysModal component to display and manage nsec/npub for accounts with local private keys.
- Updated AppHeader and Profile views to include a "My Keys" button, conditionally rendered based on the presence of a private key.
- Enhanced the useAccounts composable to determine if the active account holds a local private key, enabling key management functionality.

These changes improve user access to their private key information and enhance the overall account management experience.
2026-02-14 12:18:48 +00:00

349 lines
11 KiB
TypeScript

import { ref, computed, watch, onUnmounted } from 'vue'
import { Accounts } from 'applesauce-accounts'
import { onlyEvents } from 'applesauce-relay'
import { accountManager, AmberClipboardSigner, AmberClipboardAccount } from '../lib/accounts'
import { TEST_PERSONAS, TASTEMAKER_PERSONAS } from '../data/testPersonas'
import { pool, APP_RELAYS, LOOKUP_RELAYS } from '../lib/relay'
import type { Subscription } from 'rxjs'
type Persona = { name: string; nsec: string; pubkey: string }
/** Nostr kind 0 profile metadata */
interface NostrProfileMetadata {
name?: string
display_name?: string
picture?: string
about?: string
nip05?: string
lud16?: string
banner?: string
}
/**
* Vue composable for Nostr account management.
* Wraps applesauce AccountManager observables as Vue reactive refs.
* Provides login methods for Extension, test personas, and private keys.
*/
export function useAccounts() {
const activeAccount = ref<any>(null)
const allAccounts = ref<any[]>([])
const isLoggingIn = ref(false)
const loginError = ref<string | null>(null)
/** Cached kind 0 profile metadata keyed by pubkey */
const profileCache = ref<Map<string, NostrProfileMetadata>>(new Map())
const subscriptions: Subscription[] = []
// Subscribe to active account changes
const activeSub = accountManager.active$.subscribe((account) => {
activeAccount.value = account ?? null
})
subscriptions.push(activeSub)
// Subscribe to all accounts changes
const accountsSub = accountManager.accounts$.subscribe((accounts) => {
allAccounts.value = [...accounts]
})
subscriptions.push(accountsSub)
const isLoggedIn = computed(() => activeAccount.value !== null)
const activePubkey = computed(() => activeAccount.value?.pubkey ?? null)
/** The current user's kind 0 profile metadata (fetched from relays) */
const activeProfile = computed<NostrProfileMetadata | null>(() => {
if (!activePubkey.value) return null
return profileCache.value.get(activePubkey.value) ?? null
})
/**
* Display name: prefer Nostr profile → test persona → npub slice.
* Skips generic placeholder names like "Nostr" so the user sees
* a more useful identifier while they set up their profile.
*/
const activeName = computed(() => {
if (!activeAccount.value) return null
// First: check if we have kind 0 profile metadata
const profile = activeProfile.value
if (profile?.display_name && !/^nostr$/i.test(profile.display_name.trim())) {
return profile.display_name
}
if (profile?.name && !/^nostr$/i.test(profile.name.trim())) {
return profile.name
}
// Second: check test personas
const allPersonas: Persona[] = [
...(TEST_PERSONAS as unknown as Persona[]),
...(TASTEMAKER_PERSONAS as unknown as Persona[]),
]
const match = allPersonas.find(
(p) => p.pubkey === activeAccount.value?.pubkey,
)
if (match?.name) return match.name
// Fallback: truncated npub for a friendlier display
const pk = activeAccount.value?.pubkey
if (!pk) return 'Unknown'
return `npub...${pk.slice(-6)}`
})
/**
* Profile picture URL: prefer Nostr profile picture → robohash fallback.
*/
const activeProfilePicture = computed(() => {
const profile = activeProfile.value
if (profile?.picture) return profile.picture
if (activePubkey.value) return `https://robohash.org/${activePubkey.value}.png`
return null
})
/**
* Fetch kind 0 profile metadata for a pubkey from relays.
* Queries multiple relays and keeps the MOST RECENT event
* (highest created_at) to ensure we get the latest profile.
*/
function fetchProfile(pubkey: string) {
if (!pubkey || profileCache.value.has(pubkey)) return
// Track the best (most recent) event seen so far for this fetch
let bestCreatedAt = 0
const relays = [...APP_RELAYS, ...LOOKUP_RELAYS]
try {
const profileSub = pool
.subscription(relays, [
{ kinds: [0], authors: [pubkey], limit: 1 },
])
.pipe(onlyEvents())
.subscribe({
next: (event: any) => {
try {
// Only accept this event if it's newer than what we already have
const eventTime = event.created_at ?? 0
if (eventTime < bestCreatedAt) return
const metadata: NostrProfileMetadata = JSON.parse(event.content)
// Skip profiles with no meaningful data (empty or
// generic placeholder names without a picture)
const hasName = !!(metadata.display_name || metadata.name)
const isGeneric = hasName && /^nostr$/i.test((metadata.display_name || metadata.name || '').trim())
const hasPicture = !!metadata.picture
// Accept if: has a non-generic name, has a picture, or
// this is the first event we've received
if (bestCreatedAt === 0 || !isGeneric || hasPicture) {
bestCreatedAt = eventTime
const updated = new Map(profileCache.value)
updated.set(pubkey, metadata)
profileCache.value = updated
}
} catch {
// Invalid JSON in profile event
}
},
})
// Close subscription after 8 seconds (allows slower relays to respond)
setTimeout(() => profileSub.unsubscribe(), 8000)
subscriptions.push(profileSub)
} catch (err) {
console.error(`[useAccounts] Failed to fetch profile for ${pubkey}:`, err)
}
}
// Auto-fetch profile when active account changes
watch(activePubkey, (pubkey) => {
if (pubkey) fetchProfile(pubkey)
}, { immediate: true })
/**
* Login with a browser extension (NIP-07)
*/
async function loginWithExtension() {
isLoggingIn.value = true
loginError.value = null
try {
const account = await Accounts.ExtensionAccount.fromExtension()
accountManager.addAccount(account)
accountManager.setActive(account)
} catch (err: any) {
loginError.value = err.message || 'Extension login failed'
console.error('Extension login error:', err)
} finally {
isLoggingIn.value = false
}
}
/**
* Login with a test persona (for development)
*/
async function loginWithPersona(persona: Persona) {
isLoggingIn.value = true
loginError.value = null
try {
const account = Accounts.PrivateKeyAccount.fromKey(persona.nsec)
accountManager.addAccount(account)
accountManager.setActive(account)
} catch (err: any) {
loginError.value = err.message || 'Persona login failed'
console.error('Persona login error:', err)
} finally {
isLoggingIn.value = false
}
}
/**
* Login with a private key (nsec)
*/
async function loginWithPrivateKey(nsec: string) {
isLoggingIn.value = true
loginError.value = null
try {
const account = Accounts.PrivateKeyAccount.fromKey(nsec)
accountManager.addAccount(account)
accountManager.setActive(account)
} catch (err: any) {
loginError.value = err.message || 'Private key login failed'
console.error('Private key login error:', err)
} finally {
isLoggingIn.value = false
}
}
/**
* Check if Amber signer is supported on this platform (Android + clipboard)
*/
const isAmberSupported = computed(() => !!AmberClipboardSigner.SUPPORTED)
/**
* Login with Amber (NIP-55 Android Signer).
* Uses the AmberClipboardSigner to request the pubkey via Android intents.
* The signer is retained for future event signing (comments, reactions, etc.)
*/
async function loginWithAmber() {
isLoggingIn.value = true
loginError.value = null
try {
const signer = new AmberClipboardSigner()
const pubkey = await signer.getPublicKey()
const account = new AmberClipboardAccount(pubkey, signer)
accountManager.addAccount(account)
accountManager.setActive(account)
return pubkey
} catch (err: any) {
loginError.value = err.message || 'Amber login failed'
console.error('Amber login error:', err)
throw err
} finally {
isLoggingIn.value = false
}
}
/**
* Logout current account.
* removeAccount already clears the active account if it's the one being removed.
*/
function logout() {
const current = accountManager.active
if (current) {
try {
accountManager.removeAccount(current)
} catch (err) {
console.debug('Account removal cleanup:', err)
}
}
}
/**
* Get all available test personas
*/
const testPersonas = computed(() => [
...(TEST_PERSONAS as unknown as Persona[]),
])
const tastemakerPersonas = computed(() => [
...(TASTEMAKER_PERSONAS as unknown as Persona[]),
])
// Cleanup on unmount
onUnmounted(() => {
subscriptions.forEach((sub) => sub.unsubscribe())
})
/**
* Whether the active account holds a local private key
* (generated sovereign identity or imported nsec).
* When true, the user can view/export their keys.
*/
const hasPrivateKey = computed(() => {
const acct = activeAccount.value
if (!acct) return false
// PrivateKeyAccount has type 'local' and stores the secret key
// in its signer. Check the account type name set by applesauce.
const typeName = acct.constructor?.name ?? acct.type ?? ''
return typeName === 'PrivateKeyAccount' || typeName === 'local'
})
/**
* Retrieve the active account's nsec and npub.
* Only works for private-key-based accounts (generated or imported nsec).
* Returns null if the active account doesn't hold a local secret key.
*/
async function getAccountKeys(): Promise<{ nsec: string; npub: string; hexPub: string } | null> {
const acct = activeAccount.value
if (!acct?.pubkey) return null
try {
const nip19 = await import('nostr-tools/nip19')
const npub = nip19.npubEncode(acct.pubkey)
// The PrivateKeyAccount stores the secret key in its signer.
// Access it through the account's serialisation (toJSON includes secrets).
const signer = acct.signer ?? acct._signer
const secretKey: Uint8Array | undefined = signer?.key ?? signer?.secretKey ?? signer?._key
if (!secretKey) return null
const nsec = nip19.nsecEncode(secretKey)
return { nsec, npub, hexPub: acct.pubkey }
} catch (err) {
console.warn('[useAccounts] Failed to extract keys:', err)
return null
}
}
return {
// State
activeAccount,
allAccounts,
isLoggedIn,
isLoggingIn,
loginError,
activePubkey,
activeName,
activeProfile,
activeProfilePicture,
// Key management
hasPrivateKey,
getAccountKeys,
// Login methods
loginWithExtension,
loginWithPersona,
loginWithPrivateKey,
loginWithAmber,
logout,
// Platform checks
isAmberSupported,
// Personas for dev UI
testPersonas,
tastemakerPersonas,
}
}