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.
This commit is contained in:
Dorian
2026-02-14 12:18:48 +00:00
parent d1ac281ad9
commit 2a16802404
6 changed files with 375 additions and 1 deletions

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPaymentMethodIdToPayments1776400000000
implements MigrationInterface
{
name = 'AddPaymentMethodIdToPayments1776400000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// Add the missing payment_method_id column
await queryRunner.query(
`ALTER TABLE "payments" ADD "payment_method_id" character varying`,
);
// Add foreign key constraint to payment_methods
await queryRunner.query(
`ALTER TABLE "payments" ADD CONSTRAINT "FK_payments_payment_method_id" FOREIGN KEY ("payment_method_id") REFERENCES "payment_methods"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "payments" DROP CONSTRAINT "FK_payments_payment_method_id"`,
);
await queryRunner.query(
`ALTER TABLE "payments" DROP COLUMN "payment_method_id"`,
);
}
}

View File

@@ -10,6 +10,7 @@
@openAuth="handleOpenAuth"
@selectContent="handleSearchSelect"
@openMobileSearch="showMobileSearch = true"
@openKeys="showKeysModal = true"
/>
<!-- Route Content -->
@@ -39,6 +40,12 @@
@close="handleAuthClose"
@success="handleAuthSuccess"
/>
<!-- Keys Modal (view nsec/npub for generated accounts) -->
<KeysModal
:isOpen="showKeysModal"
@close="showKeysModal = false"
/>
<!-- Toast Notifications -->
<ToastContainer />
@@ -54,6 +61,7 @@ import { useSearchSelectionStore } from './stores/searchSelection'
import AppHeader from './components/AppHeader.vue'
import BackstageHeader from './components/BackstageHeader.vue'
import AuthModal from './components/AuthModal.vue'
import KeysModal from './components/KeysModal.vue'
import BackstageMobileNav from './components/BackstageMobileNav.vue'
import MobileNav from './components/MobileNav.vue'
import MobileSearch from './components/MobileSearch.vue'
@@ -76,6 +84,7 @@ const isBackstageRoute = computed(() =>
)
const showAuthModal = ref(false)
const showKeysModal = ref(false)
const showMobileSearch = ref(false)
const pendingRedirect = ref<string | null>(null)

View File

@@ -218,6 +218,13 @@
</svg>
<span>My Library</span>
</button>
<!-- My Keys (generated/imported private key accounts only) -->
<button v-if="hasPrivateKey" @click="handleShowKeys" class="profile-menu-item flex items-center gap-3 px-4 py-2.5 w-full text-left">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
<span>My Keys</span>
</button>
<!-- Backstage (filmmaker only) -->
<button v-if="isFilmmakerUser" @click="navigateTo('/backstage')" class="profile-menu-item flex items-center gap-3 px-4 py-2.5 w-full text-left">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -314,6 +321,7 @@ interface Emits {
(e: 'openAuth', redirect?: string): void
(e: 'selectContent', content: Content): void
(e: 'openMobileSearch'): void
(e: 'openKeys'): void
}
withDefaults(defineProps<Props>(), {
@@ -332,6 +340,7 @@ const {
activePubkey: _nostrActivePubkey,
activeName: nostrActiveName,
activeProfilePicture: nostrProfilePicture,
hasPrivateKey,
// Hidden in production for now (see template comment)
testPersonas: _testPersonas,
tastemakerPersonas: _tastemakerPersonas,
@@ -562,6 +571,11 @@ function navigateTo(path: string) {
router.push(path)
}
function handleShowKeys() {
dropdownOpen.value = false
emit('openKeys')
}
// async function handlePersonaLogin(persona: Persona) {
// personaMenuOpen.value = false
// await _loginWithPersona(persona)

View File

@@ -0,0 +1,251 @@
<template>
<Transition name="modal-fade">
<div v-if="isOpen" class="fixed inset-0 z-[10001] flex items-center justify-center p-4" @click.self="$emit('close')">
<!-- Backdrop -->
<div class="absolute inset-0 bg-black/70 backdrop-blur-sm"></div>
<!-- Modal -->
<div class="relative w-full max-w-md rounded-2xl border border-white/10 bg-[#0a0a0a]/95 backdrop-blur-xl shadow-2xl overflow-hidden">
<!-- Close button -->
<button @click="$emit('close')" class="absolute top-4 right-4 z-10 w-8 h-8 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 transition-colors text-white/60 hover:text-white">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div class="p-6 space-y-6">
<!-- Header -->
<div class="text-center pr-8">
<div class="w-12 h-12 mx-auto mb-3 rounded-full bg-[#F7931A]/15 flex items-center justify-center">
<svg class="w-6 h-6 text-[#F7931A]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
</div>
<h2 class="text-xl font-bold text-white">Your Nostr Keys</h2>
<p class="text-white/50 text-sm mt-1">These keys are your sovereign identity</p>
</div>
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-8">
<div class="w-8 h-8 border-2 border-white/20 border-t-[#F7931A] rounded-full animate-spin"></div>
</div>
<!-- Keys -->
<template v-else-if="keys">
<!-- Public Key (npub) -->
<div class="space-y-2">
<label class="block text-white/60 text-xs font-medium uppercase tracking-wider">Public Key (safe to share)</label>
<div class="relative group">
<div class="key-display break-all text-sm font-mono text-white/80 leading-relaxed">
{{ keys.npub }}
</div>
<button
@click="copyToClipboard(keys!.npub, 'npub')"
class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity p-1.5 rounded-lg bg-white/10 hover:bg-white/20 text-white/60 hover:text-white"
:title="copiedField === 'npub' ? 'Copied!' : 'Copy'"
>
<svg v-if="copiedField !== 'npub'" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<svg v-else class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</button>
</div>
</div>
<!-- Secret Key (nsec) -->
<div class="space-y-2">
<label class="block text-red-400/80 text-xs font-medium uppercase tracking-wider">Secret Key (NEVER share this!)</label>
<div class="relative group">
<div
class="key-display key-display-secret break-all text-sm font-mono leading-relaxed cursor-pointer transition-all"
:class="nsecRevealed ? 'text-red-300/80' : 'text-transparent select-none'"
@click="nsecRevealed = !nsecRevealed"
>
<span :class="{ 'blur-sm': !nsecRevealed }">{{ keys.nsec }}</span>
</div>
<div v-if="!nsecRevealed" class="absolute inset-0 flex items-center justify-center pointer-events-none">
<span class="text-white/40 text-sm font-medium">Click to reveal</span>
</div>
<button
v-if="nsecRevealed"
@click="copyToClipboard(keys!.nsec, 'nsec')"
class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity p-1.5 rounded-lg bg-white/10 hover:bg-white/20 text-white/60 hover:text-white"
:title="copiedField === 'nsec' ? 'Copied!' : 'Copy'"
>
<svg v-if="copiedField !== 'nsec'" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<svg v-else class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</button>
</div>
</div>
<!-- Warning -->
<div class="rounded-xl bg-red-500/10 border border-red-500/20 p-4">
<div class="flex gap-3">
<svg class="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<p class="text-red-300/80 text-sm leading-relaxed">
Your secret key is the <strong>only way</strong> to access this identity. If you lose it, nobody can recover it for you.
</p>
</div>
</div>
<!-- Download button -->
<button @click="downloadKeys" class="w-full flex items-center justify-center gap-2 py-3 px-4 rounded-xl bg-white/10 hover:bg-white/15 border border-white/10 text-white font-medium text-sm transition-all hover:translate-y-[-1px]">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Download Keys
</button>
</template>
<!-- No keys available -->
<div v-else class="text-center py-6">
<p class="text-white/50 text-sm">Unable to retrieve keys for this account type.</p>
</div>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useAccounts } from '../composables/useAccounts'
interface Props {
isOpen: boolean
}
interface Emits {
(e: 'close'): void
}
const props = defineProps<Props>()
defineEmits<Emits>()
const { getAccountKeys } = useAccounts()
const keys = ref<{ nsec: string; npub: string; hexPub: string } | null>(null)
const loading = ref(false)
const nsecRevealed = ref(false)
const copiedField = ref<string | null>(null)
// Fetch keys when modal opens
watch(() => props.isOpen, async (open) => {
if (open) {
loading.value = true
nsecRevealed.value = false
copiedField.value = null
try {
keys.value = await getAccountKeys()
} finally {
loading.value = false
}
} else {
// Reset on close for security
keys.value = null
nsecRevealed.value = false
}
})
async function copyToClipboard(text: string, field: string) {
try {
await navigator.clipboard.writeText(text)
copiedField.value = field
setTimeout(() => { copiedField.value = null }, 2000)
} catch {
// Fallback for older browsers
const textarea = document.createElement('textarea')
textarea.value = text
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
copiedField.value = field
setTimeout(() => { copiedField.value = null }, 2000)
}
}
function downloadKeys() {
if (!keys.value) return
const { nsec, npub } = keys.value
const content = [
'═══════════════════════════════════════════════════════',
' YOUR SOVEREIGN NOSTR IDENTITY — KEEP THIS SAFE!',
'═══════════════════════════════════════════════════════',
'',
'── Public Key (safe to share) ────────────────────────',
'npub: ' + npub,
'',
'── Secret Key (NEVER share this!) ────────────────────',
'nsec: ' + nsec,
'',
'═══════════════════════════════════════════════════════',
' WARNING: If you lose this file, your identity is',
' gone forever. Nobody can recover it for you.',
'═══════════════════════════════════════════════════════',
'',
].join('\n')
const blob = new Blob([content], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `indeedhub-keys-${npub.slice(0, 12)}.txt`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
</script>
<style scoped>
.key-display {
padding: 12px 40px 12px 14px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.key-display-secret {
background: rgba(239, 68, 68, 0.05);
border-color: rgba(239, 68, 68, 0.15);
}
/* Modal transition */
.modal-fade-enter-active {
transition: opacity 0.25s ease-out;
}
.modal-fade-leave-active {
transition: opacity 0.2s ease-in;
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
}
.modal-fade-enter-active .relative {
animation: modalSlideUp 0.3s ease-out;
}
@keyframes modalSlideUp {
from {
transform: translateY(16px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
</style>

View File

@@ -273,6 +273,48 @@ export function useAccounts() {
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,
@@ -285,6 +327,10 @@ export function useAccounts() {
activeProfile,
activeProfilePicture,
// Key management
hasPrivateKey,
getAccountKeys,
// Login methods
loginWithExtension,
loginWithPersona,

View File

@@ -14,6 +14,13 @@
<span>My Library</span>
</button>
<button v-if="hasPrivateKey" @click="showKeysModal = true" class="bar-tab">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
<span>My Keys</span>
</button>
<button @click="handleSourceToggle" class="bar-tab">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
@@ -153,6 +160,16 @@
</svg>
</button>
<button v-if="hasPrivateKey" @click="showKeysModal = true" class="profile-action-row">
<svg class="w-5 h-5 text-[#F7931A]/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
<span class="flex-1 text-white font-medium">My Keys</span>
<svg class="w-4 h-4 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
<button @click="handleSourceToggle" class="profile-action-row">
<svg class="w-5 h-5 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
@@ -172,6 +189,12 @@
</section>
</div>
</main>
<!-- Keys Modal -->
<KeysModal
:isOpen="showKeysModal"
@close="showKeysModal = false"
/>
</div>
</template>
@@ -182,12 +205,15 @@ import { useAuth } from '../composables/useAuth'
import { useAccounts } from '../composables/useAccounts'
import { useContentSourceStore } from '../stores/contentSource'
import { useContentStore } from '../stores/content'
import KeysModal from '../components/KeysModal.vue'
import { subscriptionService } from '../services/subscription.service'
import type { ApiSubscription } from '../types/api'
const router = useRouter()
const { user, linkNostr, unlinkNostr, logout: authLogout } = useAuth()
const { logout: nostrLogout } = useAccounts()
const { logout: nostrLogout, hasPrivateKey } = useAccounts()
const showKeysModal = ref(false)
const contentSourceStore = useContentSourceStore()
const contentStore = useContentStore()