- Replaced static icons with animated SVG loaders for various authentication actions, improving user feedback during loading states. - Updated button labels to reflect current actions, such as 'Signing in...' and 'Waiting...', enhancing clarity for users. - Adjusted layout spacing for buttons to ensure consistent visual presentation. These changes improve the user experience by providing clear visual cues during authentication processes.
1416 lines
48 KiB
Vue
1416 lines
48 KiB
Vue
<template>
|
|
<Transition name="modal-fade">
|
|
<div v-if="isOpen" class="auth-modal-overlay" @click.self="closeModal">
|
|
<div class="auth-modal-container">
|
|
<div class="auth-modal-content">
|
|
<!-- Close Button — matches film detail modal -->
|
|
<button @click="closeModal" class="auth-close-btn">
|
|
<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="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
|
|
<!-- ═══ NORMAL AUTH VIEW ═══ -->
|
|
<template v-if="sovereignPhase === 'normal'">
|
|
<!-- ─── REMOTE SIGNER QR PHASE (desktop) ─── -->
|
|
<template v-if="remoteSignerPhase === 'qr'">
|
|
<div class="text-center">
|
|
<button
|
|
@click="handleRemoteSignerBack"
|
|
class="flex items-center gap-2 text-white/60 hover:text-white transition-colors mb-6 w-full justify-start"
|
|
>
|
|
<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 19l-7-7 7-7" />
|
|
</svg>
|
|
Back
|
|
</button>
|
|
|
|
<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 text-left">
|
|
{{ errorMessage }}
|
|
</div>
|
|
|
|
<h2 class="text-2xl font-bold text-white mb-2">Remote Signer</h2>
|
|
<p class="text-white/50 text-sm mb-6">Scan the QR code with your phone to sign in</p>
|
|
|
|
<!-- QR Code -->
|
|
<div class="bg-white rounded-2xl p-4 inline-block mb-5">
|
|
<img v-if="remoteSignerQrDataUrl" :src="remoteSignerQrDataUrl" alt="Nostr Connect QR" class="w-56 h-56" />
|
|
<div v-else class="w-56 h-56 flex items-center justify-center text-gray-400">
|
|
Generating QR...
|
|
</div>
|
|
</div>
|
|
|
|
<p v-if="nostrConnectLoading" class="text-sm text-white/70 animate-pulse mb-4">
|
|
Waiting for signer...
|
|
</p>
|
|
|
|
<!-- Copy URI -->
|
|
<button
|
|
@click="copyRemoteSignerUri"
|
|
class="w-full bg-white/10 hover:bg-white/15 text-white rounded-xl px-4 py-3 text-sm font-medium transition-colors flex items-center justify-center gap-2 mb-4"
|
|
>
|
|
<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 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
|
|
</svg>
|
|
{{ remoteSignerCopyText }}
|
|
</button>
|
|
|
|
<p class="text-xs text-white/40">
|
|
Use Primal, Amber, or any Nostr Connect signer app
|
|
</p>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- ─── AUTH OPTIONS (idle) ─── -->
|
|
<template v-else>
|
|
<!-- Header -->
|
|
<div class="text-center mb-8">
|
|
<h2 class="text-3xl font-bold text-white">
|
|
{{ mode === 'login' ? 'Welcome Back' : 'Join IndeeHub' }}
|
|
</h2>
|
|
</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>
|
|
|
|
<!-- Auth Form — first click triggers sovereign flow; after dismiss, form is functional -->
|
|
<form class="space-y-4" :class="{ 'sovereign-trap': !sovereignDismissed }" @click.capture="triggerSovereignFlow" @submit.prevent>
|
|
<div class="form-group">
|
|
<label class="block text-white/80 text-sm font-medium mb-2">Email</label>
|
|
<input
|
|
v-if="sovereignDismissed"
|
|
type="email"
|
|
class="auth-input"
|
|
placeholder="you@example.com"
|
|
/>
|
|
<div v-else 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>
|
|
<input
|
|
v-if="sovereignDismissed"
|
|
type="password"
|
|
class="auth-input"
|
|
:placeholder="mode === 'register' ? 'Create a password' : 'Enter your password'"
|
|
/>
|
|
<div v-else class="auth-input pointer-events-none select-none text-white/30">{{ mode === 'register' ? 'Create a password' : 'Enter your password' }}</div>
|
|
</div>
|
|
<div v-if="mode === 'login'" class="text-right">
|
|
<span class="text-sm text-white/60">Forgot password?</span>
|
|
</div>
|
|
<button type="submit" class="hero-play-button w-full flex items-center justify-center mb-4">
|
|
<span>{{ mode === 'register' ? 'Create Account' : 'Sign In' }}</span>
|
|
</button>
|
|
</form>
|
|
|
|
<!-- Remote Signer — desktop: QR phase; mobile: direct link -->
|
|
<div class="mt-4 space-y-2">
|
|
<template v-if="isDesktop">
|
|
<button
|
|
@click="handleRemoteSignerClick"
|
|
:disabled="isLoading"
|
|
class="nostr-login-button w-full flex items-center justify-center gap-2"
|
|
>
|
|
<img
|
|
src="@/assets/images/primal-icon.svg"
|
|
alt="Remote Signer"
|
|
class="w-5 h-5 shrink-0"
|
|
/>
|
|
Remote Signer
|
|
</button>
|
|
</template>
|
|
<template v-else>
|
|
<button
|
|
v-if="!popupBlockedUri"
|
|
@click="handleNostrConnectLogin"
|
|
:disabled="isLoading"
|
|
class="nostr-login-button w-full flex items-center justify-center gap-2"
|
|
>
|
|
<svg v-if="nostrConnectLoading" class="w-5 h-5 shrink-0 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
</svg>
|
|
<img v-else src="@/assets/images/primal-icon.svg" alt="Primal" class="w-5 h-5 shrink-0" />
|
|
{{ nostrConnectLoading ? 'Waiting...' : 'Remote Signer' }}
|
|
</button>
|
|
<template v-else>
|
|
<p class="text-sm text-white/70 text-center">
|
|
Tap below to open your signer app:
|
|
</p>
|
|
<a
|
|
:href="popupBlockedUri"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="nostr-login-button w-full flex items-center justify-center gap-2 no-underline"
|
|
>
|
|
<img
|
|
src="@/assets/images/primal-icon.svg"
|
|
alt="Signer"
|
|
class="w-5 h-5 shrink-0"
|
|
/>
|
|
Open Signer App
|
|
</a>
|
|
</template>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Sign in with Nostr Extension (NIP-07) -->
|
|
<button
|
|
@click="handleNostrLogin"
|
|
:disabled="isLoading"
|
|
class="nostr-login-button w-full flex items-center justify-center gap-2 mt-3"
|
|
>
|
|
<svg v-if="authLoading" class="w-5 h-5 shrink-0 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
</svg>
|
|
<svg v-else class="w-5 h-5 shrink-0" 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>
|
|
{{ authLoading ? 'Signing in...' : 'Extension' }}
|
|
</button>
|
|
|
|
<!-- nsec login (hidden by default; tap to reveal field) -->
|
|
<template v-if="!showNsecField">
|
|
<button
|
|
type="button"
|
|
@click="showNsecField = true"
|
|
:disabled="isLoading"
|
|
class="nostr-login-button w-full flex items-center justify-center gap-2 mt-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>
|
|
Private Key
|
|
</button>
|
|
</template>
|
|
<div v-else class="nsec-field-block mt-3">
|
|
<input
|
|
v-model="nsecInput"
|
|
type="password"
|
|
placeholder="nsec1..."
|
|
class="auth-input w-full"
|
|
autocomplete="off"
|
|
/>
|
|
<div class="nsec-actions">
|
|
<button
|
|
type="button"
|
|
@click="handleNsecSubmit"
|
|
:disabled="!nsecInput.trim() || isLoading"
|
|
class="nostr-login-button flex-1 flex items-center justify-center gap-2"
|
|
:class="{ 'opacity-40 cursor-not-allowed': !nsecInput.trim() || isLoading }"
|
|
>
|
|
<svg v-if="authLoading" class="w-5 h-5 shrink-0 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
</svg>
|
|
{{ authLoading ? 'Signing in...' : 'Sign in' }}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
@click="cancelNsecField"
|
|
class="nsec-cancel-btn"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Amber Login (hidden for now — re-enable by changing v-if to amberPhase === 'idle') -->
|
|
<template v-if="false">
|
|
<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>
|
|
<div v-else-if="amberPhase === 'waiting-pubkey'" class="mt-3 space-y-3">
|
|
<div class="flex items-center justify-center gap-2 text-sm text-[#F7931A]">
|
|
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
|
</svg>
|
|
<span>Waiting for Amber...</span>
|
|
</div>
|
|
<p class="text-xs text-white/40 text-center">Approve the request in Amber, then come back here</p>
|
|
|
|
<!-- Paste fallback (shown after timeout) -->
|
|
<div v-if="showPasteFallback" class="space-y-2 pt-2 border-t border-white/5">
|
|
<p class="text-xs text-white/50 text-center">Clipboard didn't work? Paste your public key:</p>
|
|
<div class="flex gap-2">
|
|
<input
|
|
v-model="amberPasteInput"
|
|
type="text"
|
|
placeholder="npub1... or hex pubkey"
|
|
class="auth-input flex-1 text-xs"
|
|
/>
|
|
<button
|
|
@click="handleAmberPasteSubmit"
|
|
:disabled="!amberPasteInput.trim()"
|
|
class="amber-login-button px-4 text-xs"
|
|
:class="{ 'opacity-40 cursor-not-allowed': !amberPasteInput.trim() }"
|
|
>
|
|
Go
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
@click="cancelAmber"
|
|
class="w-full text-center text-sm text-white/40 hover:text-white/60 transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Waiting for NIP-98 signature from Amber (auto-detects on return) -->
|
|
<div v-else-if="amberPhase === 'waiting-signature'" class="mt-3 space-y-3">
|
|
<div class="flex items-center justify-center gap-2 text-sm text-[#F7931A]">
|
|
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
|
</svg>
|
|
<span>Approve sign-in in Amber...</span>
|
|
</div>
|
|
<p class="text-xs text-white/40 text-center">Approve the signing request, then come back here</p>
|
|
|
|
<!-- Paste fallback (shown after timeout) -->
|
|
<div v-if="showPasteFallback" class="space-y-2 pt-2 border-t border-white/5">
|
|
<p class="text-xs text-white/50 text-center">Clipboard didn't work? Paste the signature:</p>
|
|
<div class="flex gap-2">
|
|
<input
|
|
v-model="amberPasteInput"
|
|
type="text"
|
|
placeholder="Hex signature or signed event JSON"
|
|
class="auth-input flex-1 text-xs"
|
|
/>
|
|
<button
|
|
@click="handleAmberSignPasteSubmit"
|
|
:disabled="!amberPasteInput.trim()"
|
|
class="amber-login-button px-4 text-xs"
|
|
:class="{ 'opacity-40 cursor-not-allowed': !amberPasteInput.trim() }"
|
|
>
|
|
Go
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
@click="cancelAmber"
|
|
class="w-full text-center text-sm text-white/40 hover:text-white/60 transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
|
|
<div v-else-if="amberPhase === 'completing'" class="mt-3">
|
|
<div class="flex items-center justify-center gap-2 text-sm text-[#F7931A]">
|
|
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
|
</svg>
|
|
<span>Completing sign-in...</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Toggle Mode -->
|
|
<div class="mt-6 text-center text-sm text-white/60">
|
|
{{ mode === 'register' ? 'Already have an account?' : "Don't have an account?" }}
|
|
<button
|
|
@click="toggleMode"
|
|
class="ml-1 text-white hover:text-white/80 font-medium transition-colors"
|
|
>
|
|
{{ mode === 'register' ? 'Sign in' : 'Sign up' }}
|
|
</button>
|
|
</div>
|
|
</template>
|
|
</template>
|
|
|
|
<!-- ═══ SOVEREIGN PHASE: NAH! ═══ -->
|
|
<div v-else-if="sovereignPhase === 'nah'" class="sovereign-nah" key="nah">
|
|
<span class="nah-text">STOP!</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 v-if="sovereignGenerating" class="w-5 h-5 shrink-0 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
</svg>
|
|
<svg v-else class="w-5 h-5 shrink-0" 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>
|
|
{{ sovereignGenerating ? 'Generating...' : 'Generate Sovereign Identity' }}
|
|
</button>
|
|
<button
|
|
@click="sovereignDismissed = true; sovereignPhase = 'normal'"
|
|
class="text-sm text-red-500/70 hover:text-red-400 transition-colors italic"
|
|
>
|
|
fuck you, I wanna be lame
|
|
</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>
|
|
</Transition>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, watch } from 'vue'
|
|
import QRCode from 'qrcode'
|
|
import { useAuth } from '../composables/useAuth'
|
|
import { useAccounts } from '../composables/useAccounts'
|
|
import { useNostrConnect } from '../composables/useNostrConnect'
|
|
import { accountManager } from '../lib/accounts'
|
|
import { authService } from '../services/auth.service'
|
|
|
|
interface Props {
|
|
isOpen: boolean
|
|
defaultMode?: 'login' | 'register'
|
|
}
|
|
|
|
interface Emits {
|
|
(e: 'close'): void
|
|
(e: 'success'): void
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
defaultMode: 'register',
|
|
})
|
|
|
|
const emit = defineEmits<Emits>()
|
|
|
|
const { loginWithNostr, isLoading: authLoading } = useAuth()
|
|
const { loginWithExtension, loginWithPrivateKey } = useAccounts()
|
|
const {
|
|
loginWithRemoteSigner,
|
|
startRemoteSignerQrFlow,
|
|
isLoading: nostrConnectLoading,
|
|
error: nostrConnectError,
|
|
popupBlockedUri,
|
|
} = useNostrConnect()
|
|
|
|
const isDesktop = !/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
|
|
const remoteSignerPhase = ref<'idle' | 'qr'>('idle')
|
|
const remoteSignerQrDataUrl = ref('')
|
|
const remoteSignerCopyText = ref('Copy URI')
|
|
const remoteSignerUri = ref('')
|
|
let remoteSignerCancel: (() => void) | null = null
|
|
|
|
const mode = ref<'login' | 'register' | 'forgot'>(props.defaultMode)
|
|
const errorMessage = ref<string | null>(null)
|
|
const isLoading = computed(
|
|
() => authLoading.value || sovereignGenerating.value || nostrConnectLoading.value,
|
|
)
|
|
|
|
// nsec login (tap to reveal field)
|
|
const showNsecField = ref(false)
|
|
const nsecInput = ref('')
|
|
|
|
// Amber login state (hidden for now)
|
|
const amberPhase = ref<'idle' | 'waiting-pubkey' | 'waiting-signature' | 'completing'>('idle')
|
|
const amberPubkey = ref<string | null>(null)
|
|
const amberUnsignedEvent = ref<any>(null)
|
|
const amberPasteInput = ref('')
|
|
const showPasteFallback = ref(false)
|
|
let amberFallbackTimer: ReturnType<typeof setTimeout> | null = null
|
|
let amberVisibilityHandler: (() => void) | null = null
|
|
|
|
// ── Sovereign Identity Flow ──────────────────────────────────────
|
|
type SovereignPhase = 'normal' | 'nah' | 'own-privacy' | 'generated'
|
|
const sovereignPhase = ref<SovereignPhase>('normal')
|
|
const sovereignGenerating = ref(false)
|
|
const sovereignDismissed = 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'
|
|
sovereignDismissed.value = false
|
|
generatedKeys.value = null
|
|
errorMessage.value = null
|
|
showNsecField.value = false
|
|
nsecInput.value = ''
|
|
remoteSignerPhase.value = 'idle'
|
|
remoteSignerQrDataUrl.value = ''
|
|
remoteSignerUri.value = ''
|
|
remoteSignerCancel?.()
|
|
remoteSignerCancel = null
|
|
cancelAmber()
|
|
}
|
|
})
|
|
|
|
// Surface Nostr Connect errors in the modal
|
|
watch(nostrConnectError, (err) => {
|
|
if (err) errorMessage.value = err
|
|
})
|
|
|
|
function closeModal() {
|
|
remoteSignerCancel?.()
|
|
remoteSignerCancel = null
|
|
emit('close')
|
|
errorMessage.value = null
|
|
cancelAmber()
|
|
sovereignPhase.value = 'normal'
|
|
sovereignDismissed.value = false
|
|
generatedKeys.value = null
|
|
}
|
|
|
|
function toggleMode() {
|
|
mode.value = mode.value === 'login' ? 'register' : 'login'
|
|
errorMessage.value = null
|
|
}
|
|
|
|
/**
|
|
* Intercept clicks on the legacy auth form.
|
|
* Triggers the "STOP!" → sovereign identity animation.
|
|
* Skipped if the user already dismissed the sovereign flow.
|
|
*/
|
|
function triggerSovereignFlow(e: Event) {
|
|
if (sovereignDismissed.value) return
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
sovereignPhase.value = 'nah'
|
|
|
|
// After "STOP!" 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 {
|
|
// 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')
|
|
} catch (error: any) {
|
|
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
|
|
|
|
try {
|
|
// Check for Nostr extension (NIP-07)
|
|
if (!window.nostr) {
|
|
errorMessage.value = 'Nostr extension not found. Please install a Nostr browser extension like Alby or nos2x.'
|
|
return
|
|
}
|
|
|
|
// First, register the extension account in accountManager.
|
|
// This sets up the signer that createNip98AuthHeader needs.
|
|
await loginWithExtension()
|
|
|
|
const pubkey = accountManager.active?.pubkey
|
|
if (!pubkey) {
|
|
errorMessage.value = 'Could not get public key from extension.'
|
|
return
|
|
}
|
|
|
|
// Create backend session (NIP-98 auth is handled internally
|
|
// by authService using the active accountManager signer)
|
|
await loginWithNostr(pubkey, 'extension', {})
|
|
|
|
emit('success')
|
|
closeModal()
|
|
} catch (error: any) {
|
|
console.error('Nostr login failed:', error)
|
|
errorMessage.value = error.message || 'Nostr authentication failed. Please try again.'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Desktop: Open Remote Signer QR phase.
|
|
*/
|
|
async function handleRemoteSignerClick() {
|
|
errorMessage.value = null
|
|
remoteSignerPhase.value = 'qr'
|
|
remoteSignerQrDataUrl.value = ''
|
|
remoteSignerUri.value = ''
|
|
remoteSignerCopyText.value = 'Copy URI'
|
|
|
|
try {
|
|
const { uri, complete, cancel } = startRemoteSignerQrFlow()
|
|
remoteSignerCancel = cancel
|
|
remoteSignerUri.value = uri
|
|
|
|
const dataUrl = await QRCode.toDataURL(uri, {
|
|
width: 224,
|
|
margin: 2,
|
|
color: { dark: '#000000', light: '#FFFFFF' },
|
|
errorCorrectionLevel: 'M',
|
|
})
|
|
remoteSignerQrDataUrl.value = dataUrl
|
|
|
|
await complete
|
|
emit('success')
|
|
closeModal()
|
|
} catch (error: any) {
|
|
if (remoteSignerPhase.value === 'qr') {
|
|
errorMessage.value = error?.message || 'Remote signer login failed. Please try again.'
|
|
}
|
|
} finally {
|
|
remoteSignerCancel = null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Back from Remote Signer QR phase.
|
|
*/
|
|
function handleRemoteSignerBack() {
|
|
remoteSignerCancel?.()
|
|
remoteSignerCancel = null
|
|
remoteSignerPhase.value = 'idle'
|
|
remoteSignerQrDataUrl.value = ''
|
|
remoteSignerUri.value = ''
|
|
errorMessage.value = null
|
|
}
|
|
|
|
async function copyRemoteSignerUri() {
|
|
if (!remoteSignerUri.value) return
|
|
try {
|
|
await navigator.clipboard.writeText(remoteSignerUri.value)
|
|
remoteSignerCopyText.value = 'Copied!'
|
|
setTimeout(() => { remoteSignerCopyText.value = 'Copy URI' }, 2000)
|
|
} catch {
|
|
remoteSignerCopyText.value = 'Copy failed'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Login with Nostr via remote signer (Primal, etc.) using nostrconnect:// URI.
|
|
* Mobile: Opens the signer via link, waits for connection, then creates backend session.
|
|
*/
|
|
async function handleNostrConnectLogin() {
|
|
errorMessage.value = null
|
|
|
|
try {
|
|
await loginWithRemoteSigner()
|
|
emit('success')
|
|
closeModal()
|
|
} catch (error: any) {
|
|
console.error('Nostr Connect login failed:', error)
|
|
errorMessage.value = error?.message || 'Remote signer login failed. Please try again.'
|
|
}
|
|
}
|
|
|
|
function cancelNsecField() {
|
|
showNsecField.value = false
|
|
nsecInput.value = ''
|
|
errorMessage.value = null
|
|
}
|
|
|
|
/**
|
|
* Sign in with pasted nsec. Adds account, sets active, then creates backend session.
|
|
*/
|
|
async function handleNsecSubmit() {
|
|
const nsec = nsecInput.value.trim()
|
|
if (!nsec) return
|
|
errorMessage.value = null
|
|
try {
|
|
await loginWithPrivateKey(nsec)
|
|
const account = accountManager.active
|
|
if (!account) throw new Error('Account not set')
|
|
await loginWithNostr(account.pubkey, 'nsec', {}, undefined)
|
|
nsecInput.value = ''
|
|
showNsecField.value = false
|
|
emit('success')
|
|
closeModal()
|
|
} catch (err: any) {
|
|
errorMessage.value = err.message || 'Invalid nsec or sign-in failed.'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clean up Amber flow state, timers, and event listeners.
|
|
*/
|
|
function cancelAmber() {
|
|
amberPhase.value = 'idle'
|
|
amberPubkey.value = null
|
|
amberUnsignedEvent.value = null
|
|
amberPasteInput.value = ''
|
|
showPasteFallback.value = false
|
|
if (amberFallbackTimer) {
|
|
clearTimeout(amberFallbackTimer)
|
|
amberFallbackTimer = null
|
|
}
|
|
if (amberVisibilityHandler) {
|
|
document.removeEventListener('visibilitychange', amberVisibilityHandler)
|
|
amberVisibilityHandler = null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start listening for the user to return from Amber.
|
|
* On visibilitychange → visible, waits 500ms then tries to read
|
|
* clipboard. If clipboard read fails or is empty, shows paste fallback.
|
|
*
|
|
* @param onSuccess Called with clipboard text when auto-read succeeds.
|
|
*/
|
|
function listenForAmberReturn(onSuccess: (text: string) => void) {
|
|
// Show paste fallback after 8 seconds regardless
|
|
amberFallbackTimer = setTimeout(() => {
|
|
showPasteFallback.value = true
|
|
}, 8000)
|
|
|
|
amberVisibilityHandler = () => {
|
|
if (document.visibilityState !== 'visible') return
|
|
|
|
// User returned to browser — try clipboard after a short delay
|
|
// (same 500ms + 200ms pattern as AmberClipboardSigner)
|
|
setTimeout(async () => {
|
|
try {
|
|
if (!navigator.clipboard?.readText) {
|
|
showPasteFallback.value = true
|
|
return
|
|
}
|
|
const text = (await navigator.clipboard.readText()).trim()
|
|
if (!text) {
|
|
showPasteFallback.value = true
|
|
return
|
|
}
|
|
// Clipboard read succeeded — clean up listener and proceed
|
|
if (amberVisibilityHandler) {
|
|
document.removeEventListener('visibilitychange', amberVisibilityHandler)
|
|
amberVisibilityHandler = null
|
|
}
|
|
if (amberFallbackTimer) {
|
|
clearTimeout(amberFallbackTimer)
|
|
amberFallbackTimer = null
|
|
}
|
|
onSuccess(text)
|
|
} catch {
|
|
// Clipboard permission denied or other error — show paste input
|
|
showPasteFallback.value = true
|
|
}
|
|
}, 600)
|
|
}
|
|
|
|
document.addEventListener('visibilitychange', amberVisibilityHandler)
|
|
}
|
|
|
|
/**
|
|
* Amber Login — Step 1: Open Amber to get the public key.
|
|
* Sets up auto-detection via visibilitychange for when the user returns.
|
|
*/
|
|
function handleAmberOpen() {
|
|
errorMessage.value = null
|
|
showPasteFallback.value = false
|
|
amberPasteInput.value = ''
|
|
|
|
const isAndroid = navigator.userAgent.includes('Android')
|
|
if (!isAndroid) {
|
|
errorMessage.value = 'Amber is only available on Android devices. Please use a Nostr browser extension instead.'
|
|
return
|
|
}
|
|
|
|
// Open Amber via intent to get pubkey
|
|
const intent = 'intent:#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;S.type=get_public_key;end'
|
|
window.open(intent, '_blank')
|
|
|
|
amberPhase.value = 'waiting-pubkey'
|
|
|
|
// Auto-detect: when user returns, try to read pubkey from clipboard
|
|
listenForAmberReturn((clipboardText) => {
|
|
processPubkey(clipboardText)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Decode a pubkey from text. Handles hex, npub, nprofile, and JSON (e.g. pasted NIP-98 event).
|
|
* Normalizes pasted content: trims, strips newlines, accepts hex with extra characters.
|
|
*/
|
|
async function decodePubkeyText(text: string): Promise<string> {
|
|
const raw = text.trim().replace(/\s+/g, '')
|
|
if (!raw) throw new Error('Not a valid public key. Expected hex or npub format.')
|
|
|
|
// JSON: e.g. pasted NIP-98 request or signed event from Amber (use trim only so JSON stays valid)
|
|
if (raw.startsWith('{')) {
|
|
try {
|
|
const parsed = JSON.parse(text.trim())
|
|
if (parsed.pubkey && /^[0-9a-f]{64}$/i.test(String(parsed.pubkey))) {
|
|
return String(parsed.pubkey)
|
|
}
|
|
} catch {
|
|
// Not valid JSON, fall through
|
|
}
|
|
}
|
|
|
|
// Exact 64-char hex
|
|
if (/^[0-9a-f]{64}$/i.test(raw)) return raw
|
|
|
|
// Hex with extra chars: take first 64 hex characters
|
|
const hexOnly = raw.replace(/[^0-9a-fA-F]/g, '')
|
|
if (hexOnly.length >= 64) {
|
|
return hexOnly.slice(0, 64)
|
|
}
|
|
|
|
// npub / nprofile
|
|
const trimmed = text.trim()
|
|
if (trimmed.startsWith('npub') || trimmed.startsWith('nprofile')) {
|
|
const { decodeProfilePointer } = await import('applesauce-core/helpers')
|
|
const decoded = decodeProfilePointer(trimmed)
|
|
if (!decoded?.pubkey) throw new Error('Could not decode npub')
|
|
return decoded.pubkey
|
|
}
|
|
|
|
throw new Error('Not a valid public key. Expected hex or npub format.')
|
|
}
|
|
|
|
/**
|
|
* Process a pubkey (from auto-read or paste), then open Amber to sign NIP-98.
|
|
* If the user pasted a full signed NIP-98 event (e.g. from Amber), complete login directly.
|
|
*/
|
|
async function processPubkey(text: string) {
|
|
errorMessage.value = null
|
|
try {
|
|
// If they pasted the signed event JSON from Amber, complete login without opening Amber again
|
|
const trimmed = text.trim()
|
|
if (trimmed.startsWith('{')) {
|
|
try {
|
|
const parsed = JSON.parse(trimmed)
|
|
if (parsed.sig && parsed.pubkey && /^[0-9a-f]{64}$/i.test(String(parsed.pubkey))) {
|
|
await processSignature(trimmed)
|
|
return
|
|
}
|
|
} catch {
|
|
// Not a signed event, continue to pubkey extraction
|
|
}
|
|
}
|
|
|
|
const pubkey = await decodePubkeyText(text)
|
|
amberPubkey.value = pubkey
|
|
showPasteFallback.value = false
|
|
amberPasteInput.value = ''
|
|
|
|
// Build the unsigned NIP-98 event (name tag so Amber shows "IndeeHub" not "null")
|
|
const sessionUrl = authService.getNostrSessionUrl()
|
|
const unsignedEvent = {
|
|
kind: 27235,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [
|
|
['u', sessionUrl],
|
|
['method', 'POST'],
|
|
['name', 'IndeeHub'],
|
|
],
|
|
content: '',
|
|
pubkey,
|
|
}
|
|
amberUnsignedEvent.value = unsignedEvent
|
|
|
|
// Open Amber to sign this event
|
|
const eventJson = JSON.stringify(unsignedEvent)
|
|
const intent = `intent:${encodeURIComponent(eventJson)}#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;S.type=sign_event;end`
|
|
window.open(intent, '_blank')
|
|
|
|
amberPhase.value = 'waiting-signature'
|
|
|
|
// Auto-detect: when user returns, try to read signature from clipboard
|
|
listenForAmberReturn((clipboardText) => {
|
|
processSignature(clipboardText)
|
|
})
|
|
} catch (err: any) {
|
|
errorMessage.value = err.message || 'Invalid public key. Please try again.'
|
|
showPasteFallback.value = true
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Manual paste submit for pubkey.
|
|
*/
|
|
function handleAmberPasteSubmit() {
|
|
const text = amberPasteInput.value.trim()
|
|
if (!text) return
|
|
processPubkey(text)
|
|
}
|
|
|
|
/**
|
|
* Process a signature (from auto-read or paste) and complete login.
|
|
*/
|
|
async function processSignature(text: string) {
|
|
errorMessage.value = null
|
|
|
|
const pubkey = amberPubkey.value
|
|
if (!pubkey) { errorMessage.value = 'Lost pubkey. Please start over.'; cancelAmber(); return }
|
|
const storedEvent = amberUnsignedEvent.value
|
|
if (!storedEvent) { errorMessage.value = 'Lost signing request. Please start over.'; cancelAmber(); return }
|
|
|
|
amberPhase.value = 'completing'
|
|
|
|
try {
|
|
let authHeader: string
|
|
|
|
// Full signed event JSON
|
|
if (text.startsWith('{')) {
|
|
const signedEvent = JSON.parse(text)
|
|
if (!signedEvent.sig) throw new Error('Signed event missing signature')
|
|
authHeader = `Nostr ${btoa(JSON.stringify(signedEvent))}`
|
|
}
|
|
// Hex signature — combine with stored unsigned event
|
|
else if (/^[0-9a-f]+$/i.test(text)) {
|
|
const event = { ...storedEvent, sig: text }
|
|
const { getEventHash } = await import('nostr-tools/pure')
|
|
event.id = getEventHash(event as any)
|
|
authHeader = `Nostr ${btoa(JSON.stringify(event))}`
|
|
}
|
|
else {
|
|
throw new Error('Unrecognised signature format from Amber.')
|
|
}
|
|
|
|
// Authenticate with the pre-signed NIP-98 header
|
|
await loginWithNostr(pubkey, 'amber-nip55', {}, authHeader)
|
|
|
|
// Register the Amber account in accountManager
|
|
try {
|
|
const { AmberClipboardSigner, AmberClipboardAccount } = await import('../lib/accounts')
|
|
const signer = new AmberClipboardSigner()
|
|
signer.pubkey = pubkey
|
|
const account = new AmberClipboardAccount(pubkey, signer)
|
|
accountManager.addAccount(account)
|
|
accountManager.setActive(account)
|
|
} catch (accountErr) {
|
|
console.warn('[Amber] Account registration after login:', accountErr)
|
|
}
|
|
|
|
cancelAmber()
|
|
emit('success')
|
|
closeModal()
|
|
} catch (err: any) {
|
|
console.error('Amber sign-in failed:', err)
|
|
errorMessage.value = err.message || 'Failed to complete Amber sign-in. Please try again.'
|
|
amberPhase.value = 'waiting-signature'
|
|
showPasteFallback.value = true
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Manual paste submit for signature.
|
|
*/
|
|
function handleAmberSignPasteSubmit() {
|
|
const text = amberPasteInput.value.trim()
|
|
if (!text) return
|
|
processSignature(text)
|
|
}
|
|
|
|
// Declare window.nostr for TypeScript
|
|
declare global {
|
|
interface Window {
|
|
nostr?: {
|
|
getPublicKey: () => Promise<string>
|
|
signEvent: (event: any) => Promise<any>
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.auth-modal-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 9998;
|
|
background: rgba(0, 0, 0, 0.85);
|
|
backdrop-filter: blur(8px);
|
|
-webkit-backdrop-filter: blur(8px);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 16px;
|
|
}
|
|
|
|
.auth-modal-container {
|
|
width: 100%;
|
|
max-width: 480px;
|
|
max-height: 90vh;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.auth-modal-content {
|
|
position: relative;
|
|
background: rgba(0, 0, 0, 0.65);
|
|
backdrop-filter: blur(40px);
|
|
-webkit-backdrop-filter: blur(40px);
|
|
border-radius: 24px;
|
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
box-shadow:
|
|
0 20px 60px rgba(0, 0, 0, 0.5),
|
|
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
|
padding: 32px;
|
|
}
|
|
|
|
.auth-input {
|
|
width: 100%;
|
|
padding: 12px 16px;
|
|
background: rgba(255, 255, 255, 0.05);
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
border-radius: 12px;
|
|
color: white;
|
|
font-size: 14px;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.auth-input:focus {
|
|
outline: none;
|
|
background: rgba(255, 255, 255, 0.08);
|
|
border-color: rgba(255, 255, 255, 0.2);
|
|
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.05);
|
|
}
|
|
|
|
.auth-input::placeholder {
|
|
color: rgba(255, 255, 255, 0.3);
|
|
}
|
|
|
|
/* nsec login field block */
|
|
.nsec-field-block {
|
|
padding: 0.5rem 0;
|
|
}
|
|
.nsec-hint {
|
|
font-size: 0.8125rem;
|
|
color: rgba(255, 255, 255, 0.5);
|
|
margin-bottom: 0.5rem;
|
|
line-height: 1.4;
|
|
}
|
|
.nsec-field-block .auth-input {
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
.nsec-actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
align-items: center;
|
|
margin-top: 0.5rem;
|
|
}
|
|
.nsec-cancel-btn {
|
|
padding: 0.5rem 1rem;
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
color: rgba(255, 255, 255, 0.6);
|
|
background: transparent;
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
border-radius: 12px;
|
|
cursor: pointer;
|
|
transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease;
|
|
}
|
|
.nsec-cancel-btn:hover {
|
|
color: rgba(255, 255, 255, 0.9);
|
|
border-color: rgba(255, 255, 255, 0.2);
|
|
background: rgba(255, 255, 255, 0.05);
|
|
}
|
|
|
|
/* Modal Transitions */
|
|
.modal-fade-enter-active,
|
|
.modal-fade-leave-active {
|
|
transition: opacity 0.3s ease;
|
|
}
|
|
|
|
.modal-fade-enter-from,
|
|
.modal-fade-leave-to {
|
|
opacity: 0;
|
|
}
|
|
|
|
.modal-fade-enter-active .auth-modal-content {
|
|
transition: transform 0.3s ease, opacity 0.3s ease;
|
|
}
|
|
|
|
.modal-fade-enter-from .auth-modal-content {
|
|
transform: scale(0.95);
|
|
opacity: 0;
|
|
}
|
|
|
|
/* Nostr Extension Login Button — dark glass style */
|
|
.nostr-login-button {
|
|
position: relative;
|
|
height: 48px;
|
|
padding: 0 24px;
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
line-height: 1.4;
|
|
border-radius: 16px;
|
|
background: rgba(0, 0, 0, 0.35);
|
|
color: rgba(255, 255, 255, 0.92);
|
|
box-shadow:
|
|
0 8px 24px rgba(0, 0, 0, 0.45),
|
|
inset 0 1px 0 rgba(255, 255, 255, 0.12);
|
|
backdrop-filter: blur(24px);
|
|
-webkit-backdrop-filter: blur(24px);
|
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
cursor: pointer;
|
|
transition: all 0.3s ease;
|
|
letter-spacing: 0.02em;
|
|
}
|
|
|
|
.nostr-login-button:hover:not(:disabled) {
|
|
transform: translateY(-2px);
|
|
background: rgba(0, 0, 0, 0.5);
|
|
border-color: rgba(255, 255, 255, 0.15);
|
|
box-shadow:
|
|
0 12px 32px rgba(0, 0, 0, 0.6),
|
|
inset 0 1px 0 rgba(255, 255, 255, 0.18);
|
|
}
|
|
|
|
.nostr-login-button:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
/* Amber Login Button */
|
|
.amber-login-button {
|
|
height: 48px;
|
|
padding: 0 24px;
|
|
border-radius: 12px;
|
|
font-weight: 600;
|
|
font-size: 14px;
|
|
color: #F7931A;
|
|
background: rgba(247, 147, 26, 0.08);
|
|
border: 1px solid rgba(247, 147, 26, 0.25);
|
|
cursor: pointer;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.amber-login-button:hover {
|
|
background: rgba(247, 147, 26, 0.15);
|
|
border-color: rgba(247, 147, 26, 0.4);
|
|
box-shadow: 0 0 20px rgba(247, 147, 26, 0.1);
|
|
}
|
|
|
|
.amber-login-button--active {
|
|
background: rgba(247, 147, 26, 0.15);
|
|
border-color: rgba(247, 147, 26, 0.5);
|
|
box-shadow: 0 0 24px rgba(247, 147, 26, 0.15);
|
|
}
|
|
|
|
.amber-login-button--active:hover {
|
|
background: rgba(247, 147, 26, 0.22);
|
|
box-shadow: 0 0 30px rgba(247, 147, 26, 0.2);
|
|
}
|
|
|
|
.amber-login-button:disabled {
|
|
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;
|
|
}
|
|
|
|
/* ── Close Button — matches film detail modal ── */
|
|
.auth-close-btn {
|
|
position: absolute;
|
|
top: 16px;
|
|
right: 16px;
|
|
z-index: 20;
|
|
padding: 8px;
|
|
background: rgba(0, 0, 0, 0.6);
|
|
backdrop-filter: blur(20px);
|
|
-webkit-backdrop-filter: blur(20px);
|
|
border-radius: 9999px;
|
|
color: rgba(255, 255, 255, 0.8);
|
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.auth-close-btn:hover {
|
|
color: white;
|
|
background: rgba(0, 0, 0, 0.8);
|
|
border-color: rgba(255, 255, 255, 0.15);
|
|
}
|
|
|
|
/* ── NAH! Phase — left-to-right reveal ── */
|
|
.sovereign-nah {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 280px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.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));
|
|
|
|
/* Left-to-right fade-slide with a slight scale punch */
|
|
animation:
|
|
nahSlideIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards,
|
|
nahGlow 0.9s ease-in-out 0.3s;
|
|
}
|
|
|
|
@keyframes nahSlideIn {
|
|
0% {
|
|
opacity: 0;
|
|
transform: translateX(-60px) scale(0.85);
|
|
filter: drop-shadow(0 0 0 rgba(255, 0, 0, 0));
|
|
}
|
|
70% {
|
|
opacity: 1;
|
|
transform: translateX(8px) scale(1.05);
|
|
}
|
|
100% {
|
|
opacity: 1;
|
|
transform: translateX(0) scale(1);
|
|
filter: drop-shadow(0 0 40px rgba(255, 0, 0, 0.3));
|
|
}
|
|
}
|
|
|
|
@keyframes nahGlow {
|
|
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 — left-to-right fade ── */
|
|
.sovereign-own-privacy {
|
|
min-height: 280px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.sovereign-own-privacy > div {
|
|
animation: privacySlideIn 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 privacySlideIn {
|
|
0% {
|
|
opacity: 0;
|
|
transform: translateX(-40px);
|
|
}
|
|
100% {
|
|
opacity: 1;
|
|
transform: translateX(0);
|
|
}
|
|
}
|
|
|
|
.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>
|