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:
@@ -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"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
@openAuth="handleOpenAuth"
|
@openAuth="handleOpenAuth"
|
||||||
@selectContent="handleSearchSelect"
|
@selectContent="handleSearchSelect"
|
||||||
@openMobileSearch="showMobileSearch = true"
|
@openMobileSearch="showMobileSearch = true"
|
||||||
|
@openKeys="showKeysModal = true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Route Content -->
|
<!-- Route Content -->
|
||||||
@@ -40,6 +41,12 @@
|
|||||||
@success="handleAuthSuccess"
|
@success="handleAuthSuccess"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Keys Modal (view nsec/npub for generated accounts) -->
|
||||||
|
<KeysModal
|
||||||
|
:isOpen="showKeysModal"
|
||||||
|
@close="showKeysModal = false"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Toast Notifications -->
|
<!-- Toast Notifications -->
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
</div>
|
</div>
|
||||||
@@ -54,6 +61,7 @@ import { useSearchSelectionStore } from './stores/searchSelection'
|
|||||||
import AppHeader from './components/AppHeader.vue'
|
import AppHeader from './components/AppHeader.vue'
|
||||||
import BackstageHeader from './components/BackstageHeader.vue'
|
import BackstageHeader from './components/BackstageHeader.vue'
|
||||||
import AuthModal from './components/AuthModal.vue'
|
import AuthModal from './components/AuthModal.vue'
|
||||||
|
import KeysModal from './components/KeysModal.vue'
|
||||||
import BackstageMobileNav from './components/BackstageMobileNav.vue'
|
import BackstageMobileNav from './components/BackstageMobileNav.vue'
|
||||||
import MobileNav from './components/MobileNav.vue'
|
import MobileNav from './components/MobileNav.vue'
|
||||||
import MobileSearch from './components/MobileSearch.vue'
|
import MobileSearch from './components/MobileSearch.vue'
|
||||||
@@ -76,6 +84,7 @@ const isBackstageRoute = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const showAuthModal = ref(false)
|
const showAuthModal = ref(false)
|
||||||
|
const showKeysModal = ref(false)
|
||||||
const showMobileSearch = ref(false)
|
const showMobileSearch = ref(false)
|
||||||
const pendingRedirect = ref<string | null>(null)
|
const pendingRedirect = ref<string | null>(null)
|
||||||
|
|
||||||
|
|||||||
@@ -218,6 +218,13 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span>My Library</span>
|
<span>My Library</span>
|
||||||
</button>
|
</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) -->
|
<!-- 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">
|
<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">
|
<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: 'openAuth', redirect?: string): void
|
||||||
(e: 'selectContent', content: Content): void
|
(e: 'selectContent', content: Content): void
|
||||||
(e: 'openMobileSearch'): void
|
(e: 'openMobileSearch'): void
|
||||||
|
(e: 'openKeys'): void
|
||||||
}
|
}
|
||||||
|
|
||||||
withDefaults(defineProps<Props>(), {
|
withDefaults(defineProps<Props>(), {
|
||||||
@@ -332,6 +340,7 @@ const {
|
|||||||
activePubkey: _nostrActivePubkey,
|
activePubkey: _nostrActivePubkey,
|
||||||
activeName: nostrActiveName,
|
activeName: nostrActiveName,
|
||||||
activeProfilePicture: nostrProfilePicture,
|
activeProfilePicture: nostrProfilePicture,
|
||||||
|
hasPrivateKey,
|
||||||
// Hidden in production for now (see template comment)
|
// Hidden in production for now (see template comment)
|
||||||
testPersonas: _testPersonas,
|
testPersonas: _testPersonas,
|
||||||
tastemakerPersonas: _tastemakerPersonas,
|
tastemakerPersonas: _tastemakerPersonas,
|
||||||
@@ -562,6 +571,11 @@ function navigateTo(path: string) {
|
|||||||
router.push(path)
|
router.push(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleShowKeys() {
|
||||||
|
dropdownOpen.value = false
|
||||||
|
emit('openKeys')
|
||||||
|
}
|
||||||
|
|
||||||
// async function handlePersonaLogin(persona: Persona) {
|
// async function handlePersonaLogin(persona: Persona) {
|
||||||
// personaMenuOpen.value = false
|
// personaMenuOpen.value = false
|
||||||
// await _loginWithPersona(persona)
|
// await _loginWithPersona(persona)
|
||||||
|
|||||||
251
src/components/KeysModal.vue
Normal file
251
src/components/KeysModal.vue
Normal 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>
|
||||||
@@ -273,6 +273,48 @@ export function useAccounts() {
|
|||||||
subscriptions.forEach((sub) => sub.unsubscribe())
|
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 {
|
return {
|
||||||
// State
|
// State
|
||||||
activeAccount,
|
activeAccount,
|
||||||
@@ -285,6 +327,10 @@ export function useAccounts() {
|
|||||||
activeProfile,
|
activeProfile,
|
||||||
activeProfilePicture,
|
activeProfilePicture,
|
||||||
|
|
||||||
|
// Key management
|
||||||
|
hasPrivateKey,
|
||||||
|
getAccountKeys,
|
||||||
|
|
||||||
// Login methods
|
// Login methods
|
||||||
loginWithExtension,
|
loginWithExtension,
|
||||||
loginWithPersona,
|
loginWithPersona,
|
||||||
|
|||||||
@@ -14,6 +14,13 @@
|
|||||||
<span>My Library</span>
|
<span>My Library</span>
|
||||||
</button>
|
</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">
|
<button @click="handleSourceToggle" class="bar-tab">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</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">
|
<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">
|
<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" />
|
<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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- Keys Modal -->
|
||||||
|
<KeysModal
|
||||||
|
:isOpen="showKeysModal"
|
||||||
|
@close="showKeysModal = false"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -182,12 +205,15 @@ import { useAuth } from '../composables/useAuth'
|
|||||||
import { useAccounts } from '../composables/useAccounts'
|
import { useAccounts } from '../composables/useAccounts'
|
||||||
import { useContentSourceStore } from '../stores/contentSource'
|
import { useContentSourceStore } from '../stores/contentSource'
|
||||||
import { useContentStore } from '../stores/content'
|
import { useContentStore } from '../stores/content'
|
||||||
|
import KeysModal from '../components/KeysModal.vue'
|
||||||
import { subscriptionService } from '../services/subscription.service'
|
import { subscriptionService } from '../services/subscription.service'
|
||||||
import type { ApiSubscription } from '../types/api'
|
import type { ApiSubscription } from '../types/api'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { user, linkNostr, unlinkNostr, logout: authLogout } = useAuth()
|
const { user, linkNostr, unlinkNostr, logout: authLogout } = useAuth()
|
||||||
const { logout: nostrLogout } = useAccounts()
|
const { logout: nostrLogout, hasPrivateKey } = useAccounts()
|
||||||
|
|
||||||
|
const showKeysModal = ref(false)
|
||||||
const contentSourceStore = useContentSourceStore()
|
const contentSourceStore = useContentSourceStore()
|
||||||
const contentStore = useContentStore()
|
const contentStore = useContentStore()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user