Files
indee-demo/src/components/AuthModal.vue
Dorian ffad3eb6e8 feat: enhance AuthModal loading indicators and button labels
- 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.
2026-02-17 04:25:23 +00:00

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>