feat: sovereign identity flow — NAH! animation + keypair generation
When users click on the legacy email/password form, the form zooms out and a bold "NAH!" animates in. This transitions to an "Own your privacy" message with a "Generate Sovereign Identity" button that creates a new Nostr keypair, logs the user in, and presents a red glassmorphism "Download your new identity" button that saves the nsec/npub keypair to a text file. Uses applesauce PrivateKeyAccount.fromKey() for proper account registration and persistence. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -20,7 +20,7 @@ services:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
CACHEBUST: "22"
|
||||
CACHEBUST: "23"
|
||||
VITE_USE_MOCK_DATA: "false"
|
||||
VITE_CONTENT_ORIGIN: ${FRONTEND_URL}
|
||||
VITE_INDEEHUB_API_URL: /api
|
||||
|
||||
@@ -10,157 +10,178 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-8">
|
||||
<h2 class="text-3xl font-bold text-white mb-2">
|
||||
{{ mode === 'login' ? 'Welcome Back' : 'Join IndeedHub' }}
|
||||
</h2>
|
||||
<p class="text-white/60">
|
||||
{{ mode === 'login' ? 'Sign in to continue' : 'Create an account to get started' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div v-if="errorMessage" class="mb-4 p-3 rounded-lg bg-red-500/20 border border-red-500/30 text-red-400 text-sm">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<!-- Cognito Auth Form -->
|
||||
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||
<!-- Legal Name (Register only) -->
|
||||
<div v-if="mode === 'register'" class="form-group">
|
||||
<label class="block text-white/80 text-sm font-medium mb-2">Full Name</label>
|
||||
<input
|
||||
v-model="formData.legalName"
|
||||
type="text"
|
||||
required
|
||||
class="auth-input"
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
<!-- ═══ NORMAL AUTH VIEW ═══ -->
|
||||
<template v-if="sovereignPhase === 'normal'">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-8">
|
||||
<h2 class="text-3xl font-bold text-white mb-2">
|
||||
{{ mode === 'login' ? 'Welcome Back' : 'Join IndeedHub' }}
|
||||
</h2>
|
||||
<p class="text-white/60">
|
||||
{{ mode === 'login' ? 'Sign in to continue' : 'Create an account to get started' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div class="form-group">
|
||||
<label class="block text-white/80 text-sm font-medium mb-2">Email</label>
|
||||
<input
|
||||
v-model="formData.email"
|
||||
type="email"
|
||||
required
|
||||
class="auth-input"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
<!-- Error Message -->
|
||||
<div v-if="errorMessage" class="mb-4 p-3 rounded-lg bg-red-500/20 border border-red-500/30 text-red-400 text-sm">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div class="form-group">
|
||||
<label class="block text-white/80 text-sm font-medium mb-2">Password</label>
|
||||
<input
|
||||
v-model="formData.password"
|
||||
type="password"
|
||||
required
|
||||
class="auth-input"
|
||||
:placeholder="mode === 'login' ? 'Enter your password' : 'Create a password'"
|
||||
/>
|
||||
<!-- Cognito Auth Form — clicking anywhere triggers the sovereign flow -->
|
||||
<div class="space-y-4 sovereign-trap" @click.capture="triggerSovereignFlow">
|
||||
<div class="form-group">
|
||||
<label class="block text-white/80 text-sm font-medium mb-2">Email</label>
|
||||
<div class="auth-input pointer-events-none select-none text-white/30">you@example.com</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="block text-white/80 text-sm font-medium mb-2">Password</label>
|
||||
<div class="auth-input pointer-events-none select-none text-white/30">Enter your password</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="text-sm text-white/60">Forgot password?</span>
|
||||
</div>
|
||||
<button type="button" class="hero-play-button w-full flex items-center justify-center">
|
||||
<span>{{ mode === 'login' ? 'Sign In' : 'Create Account' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Forgot Password Link (Login only) -->
|
||||
<div v-if="mode === 'login'" class="text-right">
|
||||
<a href="#" @click.prevent="mode = 'forgot'" class="text-sm text-white/60 hover:text-white transition-colors">
|
||||
Forgot password?
|
||||
</a>
|
||||
<!-- Divider -->
|
||||
<div class="relative my-6">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-white/10"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-sm">
|
||||
<span class="px-4 bg-transparent text-white/40">or</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<!-- Nostr Login Button (NIP-07 Browser Extension) -->
|
||||
<button
|
||||
type="submit"
|
||||
@click="handleNostrLogin"
|
||||
:disabled="isLoading"
|
||||
class="hero-play-button w-full flex items-center justify-center"
|
||||
class="nostr-login-button w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
<span v-if="!isLoading">{{ mode === 'login' ? 'Sign In' : 'Create Account' }}</span>
|
||||
<span v-else>Loading...</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="relative my-6">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-white/10"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-sm">
|
||||
<span class="px-4 bg-transparent text-white/40">or</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nostr Login Button (NIP-07 Browser Extension) -->
|
||||
<button
|
||||
@click="handleNostrLogin"
|
||||
:disabled="isLoading"
|
||||
class="nostr-login-button w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
<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>
|
||||
Sign in with Nostr Extension
|
||||
</button>
|
||||
|
||||
<!-- Divider between Nostr methods -->
|
||||
<div class="relative my-4">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-white/10"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-sm">
|
||||
<span class="px-4 bg-transparent text-white/30 text-xs">or</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Amber Login (NIP-55 Android Signer) -->
|
||||
<!-- Phase 1: Open Amber to request public key -->
|
||||
<button
|
||||
v-if="amberPhase === 'idle'"
|
||||
@click="handleAmberOpen"
|
||||
:disabled="isLoading"
|
||||
class="amber-login-button w-full flex items-center justify-center gap-2 mt-3"
|
||||
>
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 2L3 7v6c0 5.25 3.75 10.15 9 11.25C17.25 23.15 21 18.25 21 13V7l-9-5z" fill="#F7931A" opacity="0.2" stroke="#F7931A" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<path d="M12 8v4m0 0v4m0-4h4m-4 0H8" stroke="#F7931A" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
Sign in with Amber
|
||||
</button>
|
||||
|
||||
<!-- Phase 2: User returned from Amber — tap to read clipboard -->
|
||||
<div v-else-if="amberPhase === 'waiting'" class="mt-3 space-y-3">
|
||||
<p class="text-sm text-white/60 text-center">
|
||||
Approved in Amber? Tap below to complete sign-in.
|
||||
</p>
|
||||
<button
|
||||
@click="handleAmberComplete"
|
||||
:disabled="isLoading"
|
||||
class="amber-login-button amber-login-button--active w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg class="w-5 h-5 animate-pulse" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 2L3 7v6c0 5.25 3.75 10.15 9 11.25C17.25 23.15 21 18.25 21 13V7l-9-5z" fill="#F7931A" opacity="0.3" stroke="#F7931A" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<path d="M9 12l2 2 4-4" stroke="#F7931A" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<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>
|
||||
Complete Sign-in
|
||||
Sign in with Nostr Extension
|
||||
</button>
|
||||
|
||||
<!-- Divider between Nostr methods -->
|
||||
<div class="relative my-4">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-white/10"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-sm">
|
||||
<span class="px-4 bg-transparent text-white/30 text-xs">or</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Amber Login (NIP-55 Android Signer) -->
|
||||
<button
|
||||
@click="amberPhase = 'idle'"
|
||||
class="w-full text-center text-sm text-white/40 hover:text-white/60 transition-colors"
|
||||
v-if="amberPhase === 'idle'"
|
||||
@click="handleAmberOpen"
|
||||
:disabled="isLoading"
|
||||
class="amber-login-button w-full flex items-center justify-center gap-2 mt-3"
|
||||
>
|
||||
Cancel
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 2L3 7v6c0 5.25 3.75 10.15 9 11.25C17.25 23.15 21 18.25 21 13V7l-9-5z" fill="#F7931A" opacity="0.2" stroke="#F7931A" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<path d="M12 8v4m0 0v4m0-4h4m-4 0H8" stroke="#F7931A" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
Sign in with Amber
|
||||
</button>
|
||||
<div v-else-if="amberPhase === 'waiting'" class="mt-3 space-y-3">
|
||||
<p class="text-sm text-white/60 text-center">
|
||||
Approved in Amber? Tap below to complete sign-in.
|
||||
</p>
|
||||
<button
|
||||
@click="handleAmberComplete"
|
||||
:disabled="isLoading"
|
||||
class="amber-login-button amber-login-button--active w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg class="w-5 h-5 animate-pulse" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 2L3 7v6c0 5.25 3.75 10.15 9 11.25C17.25 23.15 21 18.25 21 13V7l-9-5z" fill="#F7931A" opacity="0.3" stroke="#F7931A" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<path d="M9 12l2 2 4-4" stroke="#F7931A" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Complete Sign-in
|
||||
</button>
|
||||
<button
|
||||
@click="amberPhase = 'idle'"
|
||||
class="w-full text-center text-sm text-white/40 hover:text-white/60 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Toggle Mode -->
|
||||
<div class="mt-6 text-center text-sm text-white/60">
|
||||
{{ mode === 'login' ? "Don't have an account?" : "Already have an account?" }}
|
||||
<button
|
||||
@click="toggleMode"
|
||||
class="ml-1 text-white hover:text-white/80 font-medium transition-colors"
|
||||
>
|
||||
{{ mode === 'login' ? 'Sign up' : 'Sign in' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ═══ SOVEREIGN PHASE: NAH! ═══ -->
|
||||
<div v-else-if="sovereignPhase === 'nah'" class="sovereign-nah" key="nah">
|
||||
<span class="nah-text">NAH!</span>
|
||||
</div>
|
||||
|
||||
<!-- Toggle Mode -->
|
||||
<div class="mt-6 text-center text-sm text-white/60">
|
||||
{{ mode === 'login' ? "Don't have an account?" : "Already have an account?" }}
|
||||
<button
|
||||
@click="toggleMode"
|
||||
class="ml-1 text-white hover:text-white/80 font-medium transition-colors"
|
||||
>
|
||||
{{ mode === 'login' ? 'Sign up' : 'Sign in' }}
|
||||
</button>
|
||||
<!-- ═══ SOVEREIGN PHASE: Own Your Privacy ═══ -->
|
||||
<div v-else-if="sovereignPhase === 'own-privacy'" class="sovereign-own-privacy" key="own-privacy">
|
||||
<div class="text-center space-y-6">
|
||||
<h2 class="own-privacy-heading">Own your privacy.</h2>
|
||||
<p class="text-white/50 text-base">No email. No password. No surveillance.</p>
|
||||
<button
|
||||
@click="handleGenerateSovereign"
|
||||
:disabled="isLoading"
|
||||
class="nostr-login-button sovereign-generate-btn w-full flex items-center justify-center gap-3"
|
||||
>
|
||||
<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="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
{{ isLoading ? 'Generating...' : 'Generate Sovereign Identity' }}
|
||||
</button>
|
||||
<button
|
||||
@click="sovereignPhase = 'normal'"
|
||||
class="text-sm text-white/30 hover:text-white/50 transition-colors"
|
||||
>
|
||||
Go back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ SOVEREIGN PHASE: Generated — Download ═══ -->
|
||||
<div v-else-if="sovereignPhase === 'generated'" class="sovereign-generated" key="generated">
|
||||
<div class="text-center space-y-5">
|
||||
<div class="sovereign-check-circle">
|
||||
<svg class="w-10 h-10" 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>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-white">You're sovereign.</h2>
|
||||
<p class="text-white/50 text-sm max-w-xs mx-auto leading-relaxed">
|
||||
Your new identity has been created and you're signed in. Download your keys now — if you lose them, <span class="text-red-400 font-medium">nobody can recover them</span>.
|
||||
</p>
|
||||
<button
|
||||
@click="downloadKeypair"
|
||||
class="download-identity-btn w-full flex items-center justify-center gap-3"
|
||||
>
|
||||
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Download your new identity
|
||||
</button>
|
||||
<button
|
||||
@click="closeModal"
|
||||
class="text-sm text-white/40 hover:text-white/60 transition-colors"
|
||||
>
|
||||
I'll do it later (risky)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -169,7 +190,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useAuth } from '../composables/useAuth'
|
||||
import { useAccounts } from '../composables/useAccounts'
|
||||
import { accountManager } from '../lib/accounts'
|
||||
@@ -190,27 +211,40 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const { login, loginWithNostr, register, isLoading: authLoading } = useAuth()
|
||||
const { loginWithNostr, isLoading: authLoading } = useAuth()
|
||||
const { loginWithExtension } = useAccounts()
|
||||
|
||||
const mode = ref<'login' | 'register' | 'forgot'>(props.defaultMode)
|
||||
const formData = ref({
|
||||
email: '',
|
||||
password: '',
|
||||
legalName: '',
|
||||
})
|
||||
const errorMessage = ref<string | null>(null)
|
||||
const isLoading = computed(() => authLoading.value)
|
||||
const isLoading = computed(() => authLoading.value || sovereignGenerating.value)
|
||||
|
||||
// Amber two-phase login state
|
||||
const amberPhase = ref<'idle' | 'waiting'>('idle')
|
||||
|
||||
// ── Sovereign Identity Flow ──────────────────────────────────────
|
||||
type SovereignPhase = 'normal' | 'nah' | 'own-privacy' | 'generated'
|
||||
const sovereignPhase = ref<SovereignPhase>('normal')
|
||||
const sovereignGenerating = ref(false)
|
||||
|
||||
// Store the generated keypair so it can be downloaded
|
||||
const generatedKeys = ref<{ nsec: string; npub: string; hexSecret: string; hexPub: string } | null>(null)
|
||||
|
||||
// Reset sovereign phase when modal reopens
|
||||
watch(() => props.isOpen, (open) => {
|
||||
if (open) {
|
||||
sovereignPhase.value = 'normal'
|
||||
generatedKeys.value = null
|
||||
errorMessage.value = null
|
||||
amberPhase.value = 'idle'
|
||||
}
|
||||
})
|
||||
|
||||
function closeModal() {
|
||||
emit('close')
|
||||
// Reset form and Amber state
|
||||
formData.value = { email: '', password: '', legalName: '' }
|
||||
errorMessage.value = null
|
||||
amberPhase.value = 'idle'
|
||||
sovereignPhase.value = 'normal'
|
||||
generatedKeys.value = null
|
||||
}
|
||||
|
||||
function toggleMode() {
|
||||
@@ -218,23 +252,103 @@ function toggleMode() {
|
||||
errorMessage.value = null
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
/**
|
||||
* Intercept clicks on the legacy auth form.
|
||||
* Triggers the "NAH!" → sovereign identity animation.
|
||||
*/
|
||||
function triggerSovereignFlow(e: Event) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
sovereignPhase.value = 'nah'
|
||||
|
||||
// After "NAH!" animation plays, transition to the privacy message
|
||||
setTimeout(() => {
|
||||
sovereignPhase.value = 'own-privacy'
|
||||
}, 1400)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a brand new Nostr keypair, log the user in,
|
||||
* and transition to the download phase.
|
||||
*/
|
||||
async function handleGenerateSovereign() {
|
||||
errorMessage.value = null
|
||||
sovereignGenerating.value = true
|
||||
|
||||
try {
|
||||
if (mode.value === 'login') {
|
||||
await login(formData.value.email, formData.value.password)
|
||||
} else if (mode.value === 'register') {
|
||||
await register(formData.value.email, formData.value.password, formData.value.legalName)
|
||||
}
|
||||
// Dynamic import to keep bundle size lean
|
||||
const { generateSecretKey, getPublicKey } = await import('nostr-tools/pure')
|
||||
const nip19 = await import('nostr-tools/nip19')
|
||||
|
||||
const secretKey = generateSecretKey()
|
||||
const pubkey = getPublicKey(secretKey)
|
||||
|
||||
const nsec = nip19.nsecEncode(secretKey)
|
||||
const npub = nip19.npubEncode(pubkey)
|
||||
const hexSecret = Array.from(secretKey).map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
|
||||
generatedKeys.value = { nsec, npub, hexSecret, hexPub: pubkey }
|
||||
|
||||
// Register in account manager using applesauce's PrivateKeyAccount
|
||||
const { PrivateKeyAccount } = await import('applesauce-accounts/accounts/private-key-account')
|
||||
const account = PrivateKeyAccount.fromKey(secretKey)
|
||||
accountManager.addAccount(account)
|
||||
accountManager.setActive(account)
|
||||
|
||||
// Create backend auth session
|
||||
await loginWithNostr(pubkey, 'generated', {})
|
||||
|
||||
sovereignPhase.value = 'generated'
|
||||
emit('success')
|
||||
closeModal()
|
||||
} catch (error: any) {
|
||||
errorMessage.value = error.message || 'Authentication failed. Please try again.'
|
||||
console.error('Sovereign identity generation failed:', error)
|
||||
errorMessage.value = error.message || 'Failed to generate identity. Please try again.'
|
||||
} finally {
|
||||
sovereignGenerating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the generated keypair as a text file.
|
||||
*/
|
||||
function downloadKeypair() {
|
||||
if (!generatedKeys.value) return
|
||||
|
||||
const { nsec, npub, hexSecret, hexPub } = generatedKeys.value
|
||||
const content = [
|
||||
'═══════════════════════════════════════════════════════',
|
||||
' YOUR SOVEREIGN NOSTR IDENTITY — KEEP THIS SAFE!',
|
||||
'═══════════════════════════════════════════════════════',
|
||||
'',
|
||||
'Generated: ' + new Date().toISOString(),
|
||||
'',
|
||||
'── Public Key (share freely) ──────────────────────────',
|
||||
'npub: ' + npub,
|
||||
'hex: ' + hexPub,
|
||||
'',
|
||||
'── Secret Key (NEVER share this!) ────────────────────',
|
||||
'nsec: ' + nsec,
|
||||
'hex: ' + hexSecret,
|
||||
'',
|
||||
'═══════════════════════════════════════════════════════',
|
||||
' WARNING: If you lose this file, your identity is',
|
||||
' gone forever. Nobody can recover it for you.',
|
||||
' Store it somewhere safe (password manager, USB, etc.)',
|
||||
'═══════════════════════════════════════════════════════',
|
||||
'',
|
||||
].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 = `nostr-identity-${npub.slice(0, 12)}.txt`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
async function handleNostrLogin() {
|
||||
errorMessage.value = null
|
||||
|
||||
@@ -521,4 +635,173 @@ declare global {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Sovereign Identity Flow ──────────────────────────────────── */
|
||||
|
||||
/* The form area that intercepts clicks */
|
||||
.sovereign-trap {
|
||||
cursor: pointer;
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.sovereign-trap:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* ── NAH! Phase ── */
|
||||
.sovereign-nah {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 280px;
|
||||
animation: nahEntrance 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.nah-text {
|
||||
font-size: clamp(4rem, 12vw, 7rem);
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.04em;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #ff4444 50%, #ff0000 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
text-shadow: none;
|
||||
filter: drop-shadow(0 0 40px rgba(255, 0, 0, 0.3));
|
||||
animation: nahPulse 0.8s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes nahEntrance {
|
||||
0% {
|
||||
transform: scale(0.3) rotate(-5deg);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.15) rotate(2deg);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes nahPulse {
|
||||
0%, 100% { filter: drop-shadow(0 0 40px rgba(255, 0, 0, 0.3)); }
|
||||
50% { filter: drop-shadow(0 0 80px rgba(255, 0, 0, 0.6)); }
|
||||
}
|
||||
|
||||
/* ── Own Your Privacy Phase ── */
|
||||
.sovereign-own-privacy {
|
||||
min-height: 280px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: privacyFadeIn 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.own-privacy-heading {
|
||||
font-size: clamp(1.75rem, 5vw, 2.5rem);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
background: linear-gradient(to right, #ffffff, rgba(255, 255, 255, 0.6));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
@keyframes privacyFadeIn {
|
||||
0% {
|
||||
transform: translateY(20px) scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.sovereign-generate-btn {
|
||||
padding: 14px 28px !important;
|
||||
font-size: 15px !important;
|
||||
}
|
||||
|
||||
/* ── Generated / Download Phase ── */
|
||||
.sovereign-generated {
|
||||
min-height: 280px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: generatedFadeIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes generatedFadeIn {
|
||||
0% {
|
||||
transform: scale(0.9);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.sovereign-check-circle {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 0 auto;
|
||||
border-radius: 50%;
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
border: 2px solid rgba(34, 197, 94, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #22c55e;
|
||||
animation: checkPop 0.5s cubic-bezier(0.16, 1, 0.3, 1) 0.1s both;
|
||||
}
|
||||
|
||||
@keyframes checkPop {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Red glassmorphism download button */
|
||||
.download-identity-btn {
|
||||
position: relative;
|
||||
padding: 14px 28px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
border-radius: 16px;
|
||||
background: rgba(220, 38, 38, 0.2);
|
||||
color: #fca5a5;
|
||||
box-shadow:
|
||||
0 8px 32px rgba(220, 38, 38, 0.25),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border: 1px solid rgba(220, 38, 38, 0.35);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.download-identity-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
background: rgba(220, 38, 38, 0.3);
|
||||
border-color: rgba(220, 38, 38, 0.5);
|
||||
color: #fecaca;
|
||||
box-shadow:
|
||||
0 12px 40px rgba(220, 38, 38, 0.35),
|
||||
0 0 60px rgba(220, 38, 38, 0.1),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user