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: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
CACHEBUST: "22"
|
CACHEBUST: "23"
|
||||||
VITE_USE_MOCK_DATA: "false"
|
VITE_USE_MOCK_DATA: "false"
|
||||||
VITE_CONTENT_ORIGIN: ${FRONTEND_URL}
|
VITE_CONTENT_ORIGIN: ${FRONTEND_URL}
|
||||||
VITE_INDEEHUB_API_URL: /api
|
VITE_INDEEHUB_API_URL: /api
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- ═══ NORMAL AUTH VIEW ═══ -->
|
||||||
|
<template v-if="sovereignPhase === 'normal'">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="text-center mb-8">
|
<div class="text-center mb-8">
|
||||||
<h2 class="text-3xl font-bold text-white mb-2">
|
<h2 class="text-3xl font-bold text-white mb-2">
|
||||||
@@ -25,61 +27,23 @@
|
|||||||
{{ errorMessage }}
|
{{ errorMessage }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Cognito Auth Form -->
|
<!-- Cognito Auth Form — clicking anywhere triggers the sovereign flow -->
|
||||||
<form @submit.prevent="handleSubmit" class="space-y-4">
|
<div class="space-y-4 sovereign-trap" @click.capture="triggerSovereignFlow">
|
||||||
<!-- 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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Email -->
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="block text-white/80 text-sm font-medium mb-2">Email</label>
|
<label class="block text-white/80 text-sm font-medium mb-2">Email</label>
|
||||||
<input
|
<div class="auth-input pointer-events-none select-none text-white/30">you@example.com</div>
|
||||||
v-model="formData.email"
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
class="auth-input"
|
|
||||||
placeholder="you@example.com"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Password -->
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="block text-white/80 text-sm font-medium mb-2">Password</label>
|
<label class="block text-white/80 text-sm font-medium mb-2">Password</label>
|
||||||
<input
|
<div class="auth-input pointer-events-none select-none text-white/30">Enter your password</div>
|
||||||
v-model="formData.password"
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
class="auth-input"
|
|
||||||
:placeholder="mode === 'login' ? 'Enter your password' : 'Create a password'"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
<!-- Forgot Password Link (Login only) -->
|
<span class="text-sm text-white/60">Forgot password?</span>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" class="hero-play-button w-full flex items-center justify-center">
|
||||||
<!-- Submit Button -->
|
<span>{{ mode === 'login' ? 'Sign In' : 'Create Account' }}</span>
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
:disabled="isLoading"
|
|
||||||
class="hero-play-button w-full flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<span v-if="!isLoading">{{ mode === 'login' ? 'Sign In' : 'Create Account' }}</span>
|
|
||||||
<span v-else>Loading...</span>
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</div>
|
||||||
|
|
||||||
<!-- Divider -->
|
<!-- Divider -->
|
||||||
<div class="relative my-6">
|
<div class="relative my-6">
|
||||||
@@ -114,7 +78,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Amber Login (NIP-55 Android Signer) -->
|
<!-- Amber Login (NIP-55 Android Signer) -->
|
||||||
<!-- Phase 1: Open Amber to request public key -->
|
|
||||||
<button
|
<button
|
||||||
v-if="amberPhase === 'idle'"
|
v-if="amberPhase === 'idle'"
|
||||||
@click="handleAmberOpen"
|
@click="handleAmberOpen"
|
||||||
@@ -127,8 +90,6 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Sign in with Amber
|
Sign in with Amber
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Phase 2: User returned from Amber — tap to read clipboard -->
|
|
||||||
<div v-else-if="amberPhase === 'waiting'" class="mt-3 space-y-3">
|
<div v-else-if="amberPhase === 'waiting'" class="mt-3 space-y-3">
|
||||||
<p class="text-sm text-white/60 text-center">
|
<p class="text-sm text-white/60 text-center">
|
||||||
Approved in Amber? Tap below to complete sign-in.
|
Approved in Amber? Tap below to complete sign-in.
|
||||||
@@ -162,6 +123,66 @@
|
|||||||
{{ mode === 'login' ? 'Sign up' : 'Sign in' }}
|
{{ mode === 'login' ? 'Sign up' : 'Sign in' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ═══ SOVEREIGN PHASE: NAH! ═══ -->
|
||||||
|
<div v-else-if="sovereignPhase === 'nah'" class="sovereign-nah" key="nah">
|
||||||
|
<span class="nah-text">NAH!</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ 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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -169,7 +190,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { useAuth } from '../composables/useAuth'
|
import { useAuth } from '../composables/useAuth'
|
||||||
import { useAccounts } from '../composables/useAccounts'
|
import { useAccounts } from '../composables/useAccounts'
|
||||||
import { accountManager } from '../lib/accounts'
|
import { accountManager } from '../lib/accounts'
|
||||||
@@ -190,27 +211,40 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
|
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
const { login, loginWithNostr, register, isLoading: authLoading } = useAuth()
|
const { loginWithNostr, isLoading: authLoading } = useAuth()
|
||||||
const { loginWithExtension } = useAccounts()
|
const { loginWithExtension } = useAccounts()
|
||||||
|
|
||||||
const mode = ref<'login' | 'register' | 'forgot'>(props.defaultMode)
|
const mode = ref<'login' | 'register' | 'forgot'>(props.defaultMode)
|
||||||
const formData = ref({
|
|
||||||
email: '',
|
|
||||||
password: '',
|
|
||||||
legalName: '',
|
|
||||||
})
|
|
||||||
const errorMessage = ref<string | null>(null)
|
const errorMessage = ref<string | null>(null)
|
||||||
const isLoading = computed(() => authLoading.value)
|
const isLoading = computed(() => authLoading.value || sovereignGenerating.value)
|
||||||
|
|
||||||
// Amber two-phase login state
|
// Amber two-phase login state
|
||||||
const amberPhase = ref<'idle' | 'waiting'>('idle')
|
const amberPhase = ref<'idle' | 'waiting'>('idle')
|
||||||
|
|
||||||
function closeModal() {
|
// ── Sovereign Identity Flow ──────────────────────────────────────
|
||||||
emit('close')
|
type SovereignPhase = 'normal' | 'nah' | 'own-privacy' | 'generated'
|
||||||
// Reset form and Amber state
|
const sovereignPhase = ref<SovereignPhase>('normal')
|
||||||
formData.value = { email: '', password: '', legalName: '' }
|
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
|
errorMessage.value = null
|
||||||
amberPhase.value = 'idle'
|
amberPhase.value = 'idle'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
emit('close')
|
||||||
|
errorMessage.value = null
|
||||||
|
amberPhase.value = 'idle'
|
||||||
|
sovereignPhase.value = 'normal'
|
||||||
|
generatedKeys.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleMode() {
|
function toggleMode() {
|
||||||
@@ -218,23 +252,103 @@ function toggleMode() {
|
|||||||
errorMessage.value = null
|
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
|
errorMessage.value = null
|
||||||
|
sovereignGenerating.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (mode.value === 'login') {
|
// Dynamic import to keep bundle size lean
|
||||||
await login(formData.value.email, formData.value.password)
|
const { generateSecretKey, getPublicKey } = await import('nostr-tools/pure')
|
||||||
} else if (mode.value === 'register') {
|
const nip19 = await import('nostr-tools/nip19')
|
||||||
await register(formData.value.email, formData.value.password, formData.value.legalName)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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')
|
emit('success')
|
||||||
closeModal()
|
|
||||||
} catch (error: any) {
|
} 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() {
|
async function handleNostrLogin() {
|
||||||
errorMessage.value = null
|
errorMessage.value = null
|
||||||
|
|
||||||
@@ -521,4 +635,173 @@ declare global {
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user