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:
Dorian
2026-02-13 22:43:23 +00:00
parent 941d8917f3
commit 90cdd030e1
2 changed files with 435 additions and 152 deletions

View File

@@ -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

View File

@@ -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>